Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- KEV due dates: each KEV finding carries `kev_due_date` (KEV `dueDate`, or NVD `cisaActionDue` when no KEV file is loaded) and `kev_overdue_days` relative to the run's UTC date. Shown in Markdown, HTML, JSON (`as_of`) and as two new trailing CSV columns. Points are unchanged.
- `--fail-on kev-overdue`: exit 4 when a KEV finding is past its due date.
- Ivanti-style release strings (`22.8R2.4`) are split into CPE version `22.8` and update `r2.4`.
- Demo: fictional VPN gateway rows on Ivanti Connect Secure 22.8R2.4 and 22.8R2.5 with recorded NVD, KEV (catalog 2026.10.30) and EPSS data for CVE-2026-104935 and CVE-2026-111357.

## 0.2.0 - 2026-10-28

- `--fail-on kev|new|internet|priority:N` on `scan` and `demo` (repeatable). Exit 4 when a gate matches; the briefing is still printed.
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ python -m pip install -e .
python -m patchorder demo
```

`demo` reads bundled fixtures recorded on 2026-10-21 and does not use the network. Add `--format html` or `--format json`. `--output briefing.md` is the explicit way to write a file. With no `--output`, nothing is written.
`demo` reads bundled fixtures recorded on 2026-10-21 (Ivanti Connect Secure records on 2026-11-03) and does not use the network. Add `--format html` or `--format json`. `--output briefing.md` is the explicit way to write a file. With no `--output`, nothing is written.

```bash
python -m patchorder demo --format html --output briefing.html
Expand All @@ -54,6 +54,17 @@ python -m patchorder demo --llm-preview

`--llm-preview` prints the request body and does not send it. The body is CVE identifiers and description text only.

### KEV due dates

Every CISA KEV entry has a `dueDate`. patchorder copies it into each KEV finding and counts the days past it on the run's UTC date (`as_of` in JSON). It reads `cisaActionDue` from the NVD record when no KEV file was loaded. The date shows up in the top actions, the Markdown details, the HTML table, JSON (`kev_due_date`, `kev_overdue_days`), and two new CSV columns at the end. Points do not change. KEV listing already scores 40 points, and a due date set for US federal agencies is not a risk measure for other estates.

The demo has two fictional VPN gateway rows on Ivanti Connect Secure. The CVEs, KEV entries, NVD configurations, and EPSS scores are real recordings:

- `vpn-gateway`, 22.8R2.4, internet-facing: [CVE-2026-104935](https://nvd.nist.gov/vuln/detail/CVE-2026-104935) (KEV added 2026-10-13, due 2026-10-16) and [CVE-2026-111357](https://nvd.nist.gov/vuln/detail/CVE-2026-111357) (KEV added 2026-10-30, due 2026-11-02).
- `vpn-gateway-lab`, 22.8R2.5, internal: only CVE-2026-111357. NVD lists 22.8 r2.5 for that CVE but not for CVE-2026-104935.

Ivanti release strings such as `22.8R2.4` are split into CPE version `22.8` and update `r2.4`, the way NVD writes them.

### Start from a spreadsheet

Most inventories already live in a CMDB export or a spreadsheet. Turn the CSV into inventory YAML, then lint it before the first scan:
Expand All @@ -73,9 +84,10 @@ python -m patchorder inventory check inventory.yaml
python -m patchorder scan -i inventory.yaml --fail-on kev --fail-on priority:70
python -m patchorder scan -i inventory.yaml --state .patchorder/state.json --save-state --fail-on new
python -m patchorder demo --fail-on kev # exits 4: nginx 1.25.2 is affected by a KEV-listed CVE
python -m patchorder demo --fail-on kev-overdue # exits 4: KEV due dates on nginx and the VPN gateways have passed
```

The briefing is printed either way. `--fail-on` is repeatable: `kev` (any finding in the loaded KEV catalog), `new` (a CVE that was not in `--state`), `internet` (any finding on an internet-facing row), or `priority:N` (any finding at or above N points). Exit codes: 0 clean, 1 error, 2 usage, 3 offline cache miss, 4 a gate matched.
The briefing is printed either way. `--fail-on` is repeatable: `kev` (any finding in the loaded KEV catalog), `kev-overdue` (a KEV finding whose due date is before the run's UTC date), `new` (a CVE that was not in `--state`), `internet` (any finding on an internet-facing row), or `priority:N` (any finding at or above N points). Exit codes: 0 clean, 1 error, 2 usage, 3 offline cache miss, 4 a gate matched.

### Findings as CSV

Expand Down Expand Up @@ -263,3 +275,5 @@ Die Priorität setzt sich aus CISA KEV, EPSS, CVSS, Erreichbarkeit, Anzahl und e
Es wird keine eigene Datenbank aufgebaut. Ein Live-Lauf fragt Vulnerability-Lookup, die NVD-API 2.0, den CISA-KEV-Katalog, die EPSS-API und lokal vorliegende CSAF-Dokumente (CERT-Bund veröffentlicht sie unter <https://wid.cert-bund.de/.well-known/csaf/>). Der vollständige WID-Feed wird nicht heruntergeladen. `patchorder demo` läuft ohne Netz auf mitgelieferten Beispieldaten. Es gibt keine Telemetrie. Das Inventar bleibt auf dem Rechner. Ein optionales LLM ist standardmäßig aus und bekommt nur CVE-Texte, nie das Inventar.

Neu in 0.2: `inventory from-csv` macht aus einem CSV-Export ein Inventar, `inventory check` prüft es offline, und `--fail-on kev|new|internet|priority:N` beendet einen CI- oder Nachtlauf mit Exit-Code 4, wenn ein passender Fund auftaucht.

Neu: Jeder KEV-Fund nennt das `dueDate` aus dem CISA-Katalog und die Tage seit Ablauf. `--fail-on kev-overdue` schlägt nur bei abgelaufener Frist an. Die Punkte ändern sich dadurch nicht. Die Demo enthält zwei fiktive VPN-Gateways mit Ivanti Connect Secure und den echten KEV-Einträgen CVE-2026-104935 und CVE-2026-111357.
12 changes: 12 additions & 0 deletions examples/inventory.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,15 @@ entries:
confidence: 1.0
origin: user
note: Pinned so the briefing can show a marketing name is not a build.
vpn-gateway:
cpe: cpe:2.3:a:ivanti:connect_secure
pinned: true
confidence: 1.0
origin: user
note: Pinned for the fictional Harbor Workshop demo.
vpn-gateway-lab:
cpe: cpe:2.3:a:ivanti:connect_secure
pinned: true
confidence: 1.0
origin: user
note: Pinned for the fictional Harbor Workshop demo.
14 changes: 14 additions & 0 deletions examples/inventory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,17 @@ assets:
count: 4
owner: apps
exposure: internal
- id: vpn-gateway
product: Ivanti Connect Secure
vendor: Ivanti
version: "22.8R2.4"
count: 2
owner: network
exposure: internet
- id: vpn-gateway-lab
product: Ivanti Connect Secure
vendor: Ivanti
version: "22.8R2.5"
count: 1
owner: network
exposure: internal
40 changes: 33 additions & 7 deletions src/patchorder/briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,28 @@
from patchorder.report import Finding, Report


def _action(finding: Finding, extra: int) -> str:
def _due_text(finding: Finding, as_of: str | None) -> str:
"""One clause about the KEV dueDate, or an empty string when there is none."""
if not finding.kev_due_date:
return ""
days = finding.kev_overdue_days
if days:
unit = "day" if days == 1 else "days"
return f"KEV due date {finding.kev_due_date} passed {days} {unit} before {as_of}"
return f"KEV due date {finding.kev_due_date}"


def _action(finding: Finding, extra: int, as_of: str | None = None) -> str:
where = "internet-facing" if finding.exposure == "internet" else "internal"
sentence = (
f"Patch {finding.product} {finding.version} on {finding.count} {where} hosts "
f"(owner {finding.owner})."
)
if finding.kev:
sentence += f" {finding.cve_id} is listed in CISA KEV (exploited in the wild)."
due = _due_text(finding, as_of)
if due:
sentence += f" {due}."
else:
sentence += f" Highest issue: {finding.cve_id}."
if finding.fixed_hint:
Expand All @@ -36,7 +50,7 @@ def _top_actions(report: Report) -> list[tuple[Finding, int]]:
return [(items[0], len(items) - 1) for items in ranked[:10]]


def _md_finding(finding: Finding) -> str:
def _md_finding(finding: Finding, as_of: str | None = None) -> str:
severity = finding.severity or "unscored"
cvss = "n/a" if finding.cvss is None else f"{finding.cvss:.1f}"
pin = "pinned" if finding.cpe_confirmed else "not pinned"
Expand All @@ -59,6 +73,7 @@ def _md_finding(finding: Finding) -> str:
lines.append(
f"- CISA KEV: {finding.kev_name or finding.cve_id}"
+ (f", added {finding.kev_date}" if finding.kev_date else "")
+ (f", {_due_text(finding, as_of)}" if finding.kev_due_date else "")
)
if finding.ransomware:
lines.append("- Known ransomware campaign use: yes (CISA flag)")
Expand Down Expand Up @@ -90,7 +105,7 @@ def render_markdown(report: Report) -> str:
lines.append("No affected versions in the records loaded for this run.")
else:
for index, (finding, extra) in enumerate(actions, start=1):
lines.append(f"{index}. {_action(finding, extra)} Priority {finding.points:.2f}.")
lines.append(f"{index}. {_action(finding, extra, report.as_of)} Priority {finding.points:.2f}.")
lines.append("")
lines.append("## New since last run")
lines.append("")
Expand All @@ -114,7 +129,7 @@ def render_markdown(report: Report) -> str:
lines.append("None.")
else:
for finding in report.findings:
lines.append(_md_finding(finding))
lines.append(_md_finding(finding, report.as_of))
lines.append("")
lines.append("## Not affected")
lines.append("")
Expand Down Expand Up @@ -195,7 +210,7 @@ def esc(value: object) -> str:
items = []
for finding, extra in actions:
klass = "kev" if finding.kev else ""
items.append(f"<li class=\"{klass}\">{esc(_action(finding, extra))} Priority {finding.points:.2f}.</li>")
items.append(f"<li class=\"{klass}\">{esc(_action(finding, extra, report.as_of))} Priority {finding.points:.2f}.</li>")
action_html = "<ol>" + "".join(items) + "</ol>"
rows = []
for finding in report.findings:
Expand All @@ -204,7 +219,7 @@ def esc(value: object) -> str:
f"<td><code>{esc(finding.cve_id)}</code></td>"
f"<td>{esc(finding.product)} {esc(finding.version)}<br><code>{esc(finding.cpe)}</code></td>"
f"<td>{finding.points:.2f}</td>"
f"<td>{'KEV' if finding.kev else ''}</td>"
f"<td>{_kev_cell(finding)}</td>"
f"<td>{esc(finding.fixed_hint or '')}</td>"
"</tr>"
)
Expand Down Expand Up @@ -279,6 +294,14 @@ def esc(value: object) -> str:
"""


def _kev_cell(finding: Finding) -> str:
if not finding.kev:
return ""
if finding.kev_overdue_days:
return f"KEV, overdue {finding.kev_overdue_days} d"
return "KEV"


def _finding_dict(finding: Finding) -> dict:
return {
"asset_id": finding.asset_id,
Expand All @@ -301,6 +324,8 @@ def _finding_dict(finding: Finding) -> dict:
"kev": finding.kev,
"kev_name": finding.kev_name,
"kev_date": finding.kev_date,
"kev_due_date": finding.kev_due_date,
"kev_overdue_days": finding.kev_overdue_days,
"points": finding.points,
"score": [{"points": part.points, "text": part.text} for part in finding.parts],
"reason": finding.reason,
Expand All @@ -312,12 +337,13 @@ def _finding_dict(finding: Finding) -> dict:

def render_json(report: Report) -> str:
actions = [
{"asset_id": finding.asset_id, "cve_id": finding.cve_id, "points": finding.points, "text": _action(finding, extra)}
{"asset_id": finding.asset_id, "cve_id": finding.cve_id, "points": finding.points, "text": _action(finding, extra, report.as_of)}
for finding, extra in _top_actions(report)
]
payload = {
"tool": "patchorder",
"generated_at": report.generated_at,
"as_of": report.as_of,
"mode": report.mode,
"read_only": True,
"inventory_name": report.name,
Expand Down
4 changes: 2 additions & 2 deletions src/patchorder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def _format_args(parser: argparse.ArgumentParser) -> None:
action="append",
default=[],
metavar="GATE",
help="exit 4 when a finding matches: kev, new (needs --state), internet, or priority:N (repeatable)",
help="exit 4 when a finding matches: kev, kev-overdue, new (needs --state), internet, or priority:N (repeatable)",
)


Expand Down Expand Up @@ -157,7 +157,7 @@ def cmd_demo(args: argparse.Namespace) -> int:
if args.llm_preview:
report.llm_preview = preview_json(cve_texts(report.findings), os.environ.get("PATCHORDER_LLM_MODEL", "unset"))
_emit(_render(report, args.format), args.output)
print("patchorder demo: offline, fixtures recorded 2026-10-21", file=sys.stderr)
print("patchorder demo: offline, fixtures recorded 2026-10-21 and 2026-11-03", file=sys.stderr)
return _apply_gates(report, gates)


Expand Down
2 changes: 2 additions & 0 deletions src/patchorder/csvout.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
COLUMNS = (
"priority", "asset_id", "product", "vendor", "version", "count", "owner", "exposure",
"cve_id", "kev", "epss", "cvss", "severity", "fixed_hint", "cpe", "cpe_confirmed", "new_since_last_run",
"kev_due_date", "kev_overdue_days",
)

_FORMULA_START = ("=", "+", "-", "@", "\t", "\r")
Expand Down Expand Up @@ -40,5 +41,6 @@ def render_csv(report: Report) -> str:
_cell(f.count), _cell(f.owner), _cell(f.exposure), _cell(f.cve_id), _cell(f.kev), _cell(f.epss),
_cell(f.cvss), _cell(f.severity), _cell(f.fixed_hint), _cell(f.cpe), _cell(f.cpe_confirmed),
_cell(f.cve_id in new) if report.new_cve_ids is not None else "",
_cell(f.kev_due_date), _cell(f.kev_overdue_days),
])
return buffer.getvalue()
4 changes: 2 additions & 2 deletions src/patchorder/data/demo/MANIFEST.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"recorded": "2026-10-21",
"note": "NVD and KEV bodies are reduced recordings: English description, CVSS base score, and the CPE matches the demo decides. They are not full upstream documents. The EPSS file and KEV catalog header are recorded responses. The CSAF files are fictional (Example CERT and a patchorder fixture), not copies of published advisories. The KEV vulnerabilities array contains 1 of the catalog's published entries.",
"recorded": "2026-10-21; Ivanti Connect Secure records, KEV header and the two Ivanti EPSS rows 2026-11-03",
"note": "NVD and KEV bodies are reduced recordings: English description, CVSS base score, and the CPE matches the demo decides. They are not full upstream documents. The EPSS file and KEV catalog header are recorded responses. The CSAF files are fictional (Example CERT and a patchorder fixture), not copies of published advisories. The KEV vulnerabilities array contains 3 of the catalog's published entries (catalog 2026.10.30). The Ivanti Connect Secure rows in the inventory are fictional hosts; the CVE ids, KEV entries, NVD configurations and EPSS scores are real recordings.",
"sources": {
"nvd_cve": "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=",
"epss": "https://api.first.org/data/v1/epss",
Expand Down
14 changes: 13 additions & 1 deletion src/patchorder/data/demo/epss.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"status-code": 200,
"version": "1.0",
"access": "public",
"total": 4,
"total": 6,
"offset": 0,
"limit": 100,
"data": [
Expand All @@ -30,6 +30,18 @@
"epss": "0.999990000",
"percentile": "0.999980000",
"date": "2026-10-27"
},
{
"cve": "CVE-2026-111357",
"epss": "0.005330000",
"percentile": "0.450140000",
"date": "2026-11-02"
},
{
"cve": "CVE-2026-104935",
"epss": "0.867660000",
"percentile": "0.997420000",
"date": "2026-11-02"
}
]
}
12 changes: 12 additions & 0 deletions src/patchorder/data/demo/inventory.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,15 @@ entries:
confidence: 1.0
origin: user
note: Pinned so the briefing can show a marketing name is not a build.
vpn-gateway:
cpe: cpe:2.3:a:ivanti:connect_secure
pinned: true
confidence: 1.0
origin: user
note: Pinned for the fictional Harbor Workshop demo.
vpn-gateway-lab:
cpe: cpe:2.3:a:ivanti:connect_secure
pinned: true
confidence: 1.0
origin: user
note: Pinned for the fictional Harbor Workshop demo.
14 changes: 14 additions & 0 deletions src/patchorder/data/demo/inventory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,17 @@ assets:
count: 4
owner: apps
exposure: internal
- id: vpn-gateway
product: Ivanti Connect Secure
vendor: Ivanti
version: "22.8R2.4"
count: 2
owner: network
exposure: internet
- id: vpn-gateway-lab
product: Ivanti Connect Secure
vendor: Ivanti
version: "22.8R2.5"
count: 1
owner: network
exposure: internal
40 changes: 37 additions & 3 deletions src/patchorder/data/demo/kev.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"title": "CISA Catalog of Known Exploited Vulnerabilities",
"catalogVersion": "2026.10.26",
"dateReleased": "2026-10-26T22:24:24.6101Z",
"count": 1754,
"catalogVersion": "2026.10.30",
"dateReleased": "2026-10-30T17:02:45.3665Z",
"count": 1766,
"vulnerabilities": [
{
"cveID": "CVE-2023-44487",
Expand All @@ -19,6 +19,40 @@
"cwes": [
"CWE-400"
]
},
{
"cveID": "CVE-2026-104935",
"vendorProject": "Ivanti",
"product": "Connect Secure",
"vulnerabilityName": "Ivanti Connect Secure Authentication Bypass by Spoofing Vulnerability",
"dateAdded": "2026-10-13",
"shortDescription": "Ivanti Connect Secure contains an authentication bypass by spoofing vulnerability that allows a remote unauthenticated attacker to execute arbitrary commands.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA’s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA’s “Forensics Triage Requirements” (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-10-16",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "Yes",
"notes": "https:/forums.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935 ; https://nvd.nist.gov/vuln/detail/CVE-2026-104935",
"cwes": [
"CWE-290"
]
},
{
"cveID": "CVE-2026-111357",
"vendorProject": "Ivanti",
"product": "Connect Secure",
"vulnerabilityName": "Ivanti Connect Secure Missing Authentication for Critical Function Vulnerability",
"dateAdded": "2026-10-30",
"shortDescription": "Ivanti Connect Secure contains a missing authentication for critical function vulnerability that could allow a local authenticated attacker to escalate their privileges. This vulnerability can be chained with CVE-2026-104935 and CVE-2026-118527.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA’s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA’s “Forensics Triage Requirements” (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-02",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "Yes",
"notes": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935?language=en_US ; https://www.cisa.gov/news-events/alerts/2026/10/13/cisa-urges-patching-newly-exploited-vulnerability-ivanti-connect-secure ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-111357",
"cwes": [
"CWE-306",
"CWE-862",
"CWE-345"
]
}
]
}
Loading
Loading