diff --git a/CHANGELOG.md b/CHANGELOG.md index 321ac4b..fe1fce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2fbea6b..53bc6d5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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 @@ -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 ). 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. diff --git a/examples/inventory.lock.yaml b/examples/inventory.lock.yaml index 28e571f..15c5908 100644 --- a/examples/inventory.lock.yaml +++ b/examples/inventory.lock.yaml @@ -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. diff --git a/examples/inventory.yaml b/examples/inventory.yaml index 3fc58ac..0c381d7 100644 --- a/examples/inventory.yaml +++ b/examples/inventory.yaml @@ -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 diff --git a/src/patchorder/briefing.py b/src/patchorder/briefing.py index a8434dc..c8531b2 100644 --- a/src/patchorder/briefing.py +++ b/src/patchorder/briefing.py @@ -9,7 +9,18 @@ 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 " @@ -17,6 +28,9 @@ def _action(finding: Finding, extra: int) -> str: ) 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: @@ -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" @@ -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)") @@ -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("") @@ -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("") @@ -195,7 +210,7 @@ def esc(value: object) -> str: items = [] for finding, extra in actions: klass = "kev" if finding.kev else "" - items.append(f"
  • {esc(_action(finding, extra))} Priority {finding.points:.2f}.
  • ") + items.append(f"
  • {esc(_action(finding, extra, report.as_of))} Priority {finding.points:.2f}.
  • ") action_html = "
      " + "".join(items) + "
    " rows = [] for finding in report.findings: @@ -204,7 +219,7 @@ def esc(value: object) -> str: f"{esc(finding.cve_id)}" f"{esc(finding.product)} {esc(finding.version)}
    {esc(finding.cpe)}" f"{finding.points:.2f}" - f"{'KEV' if finding.kev else ''}" + f"{_kev_cell(finding)}" f"{esc(finding.fixed_hint or '')}" "" ) @@ -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, @@ -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, @@ -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, diff --git a/src/patchorder/cli.py b/src/patchorder/cli.py index 0a45108..b5d6961 100644 --- a/src/patchorder/cli.py +++ b/src/patchorder/cli.py @@ -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)", ) @@ -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) diff --git a/src/patchorder/csvout.py b/src/patchorder/csvout.py index b1e9409..022c322 100644 --- a/src/patchorder/csvout.py +++ b/src/patchorder/csvout.py @@ -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") @@ -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() diff --git a/src/patchorder/data/demo/MANIFEST.json b/src/patchorder/data/demo/MANIFEST.json index 10f8a05..29db4ea 100644 --- a/src/patchorder/data/demo/MANIFEST.json +++ b/src/patchorder/data/demo/MANIFEST.json @@ -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", diff --git a/src/patchorder/data/demo/epss.json b/src/patchorder/data/demo/epss.json index ddfb8ca..6475858 100644 --- a/src/patchorder/data/demo/epss.json +++ b/src/patchorder/data/demo/epss.json @@ -3,7 +3,7 @@ "status-code": 200, "version": "1.0", "access": "public", - "total": 4, + "total": 6, "offset": 0, "limit": 100, "data": [ @@ -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" } ] } diff --git a/src/patchorder/data/demo/inventory.lock.yaml b/src/patchorder/data/demo/inventory.lock.yaml index 28e571f..15c5908 100644 --- a/src/patchorder/data/demo/inventory.lock.yaml +++ b/src/patchorder/data/demo/inventory.lock.yaml @@ -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. diff --git a/src/patchorder/data/demo/inventory.yaml b/src/patchorder/data/demo/inventory.yaml index 3fc58ac..0c381d7 100644 --- a/src/patchorder/data/demo/inventory.yaml +++ b/src/patchorder/data/demo/inventory.yaml @@ -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 diff --git a/src/patchorder/data/demo/kev.json b/src/patchorder/data/demo/kev.json index 5aef217..56eab47 100644 --- a/src/patchorder/data/demo/kev.json +++ b/src/patchorder/data/demo/kev.json @@ -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", @@ -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" + ] } ] } diff --git a/src/patchorder/data/demo/nvd/CVE-2026-104935.json b/src/patchorder/data/demo/nvd/CVE-2026-104935.json new file mode 100644 index 0000000..8e9bf5f --- /dev/null +++ b/src/patchorder/data/demo/nvd/CVE-2026-104935.json @@ -0,0 +1,109 @@ +{ + "resultsPerPage": 1, + "startIndex": 0, + "totalResults": 1, + "format": "NVD_CVE", + "version": "2.0", + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2026-104935", + "sourceIdentifier": "3c1d8aa1-5a33-4ea4-8992-aadd6440af75", + "published": "2026-10-13T16:17:11.640", + "lastModified": "2026-10-13T19:24:36.243", + "vulnStatus": "Analyzed", + "cisaExploitAdd": "2026-10-13", + "cisaActionDue": "2026-10-16", + "cisaRequiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (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.", + "cisaVulnerabilityName": "Ivanti Connect Secure Authentication Bypass by Spoofing Vulnerability", + "descriptions": [ + { + "lang": "en", + "value": "Authentication bypass by spoofing in ICS before version 25.1.3.0 allows a remote unauthenticated attacker to execute arbitrary commands. This does not impact ICS version 22.7R2.14 or prior." + } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "3c1d8aa1-5a33-4ea4-8992-aadd6440af75", + "type": "Secondary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:L", + "baseScore": 8.6, + "baseSeverity": "HIGH" + } + } + ] + }, + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:*:*:*:*:*:*:*:*", + "versionStartIncluding": "25.1.0.0", + "versionEndExcluding": "25.1.1.2", + "matchCriteriaId": "51F9201D-EF2F-4750-846E-912827761A9F" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:*:*:*:*:*:*:*:*", + "versionStartIncluding": "25.1.2.0", + "versionEndExcluding": "25.1.3.0", + "matchCriteriaId": "822E7CEB-9AB2-4B0E-B87A-B9718503466A" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2:*:*:*:*:*:*", + "matchCriteriaId": "56FEC223-8FF7-4A05-9577-CFFE0AA6526C" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.1:*:*:*:*:*:*", + "matchCriteriaId": "5569D323-6ADF-4B16-822D-FBA6760356C3" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.2:*:*:*:*:*:*", + "matchCriteriaId": "FF4D400F-49DA-4C5F-8F63-5A15738360AE" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.3:*:*:*:*:*:*", + "matchCriteriaId": "9CA7EF35-D0BC-4714-A38A-91660BE1BA55" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.4:*:*:*:*:*:*", + "matchCriteriaId": "F22EC396-A447-4175-8A30-C8DC10ACFFE9" + } + ] + } + ] + } + ], + "references": [ + { + "url": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935", + "source": "3c1d8aa1-5a33-4ea4-8992-aadd6440af75", + "tags": [ + "Vendor Advisory" + ] + }, + { + "url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-104935", + "source": "134c704f-9b21-4f2e-91b3-4a467353bcc0", + "tags": [ + "US Government Resource" + ] + } + ] + } + } + ] +} diff --git a/src/patchorder/data/demo/nvd/CVE-2026-111357.json b/src/patchorder/data/demo/nvd/CVE-2026-111357.json new file mode 100644 index 0000000..27094f0 --- /dev/null +++ b/src/patchorder/data/demo/nvd/CVE-2026-111357.json @@ -0,0 +1,114 @@ +{ + "resultsPerPage": 1, + "startIndex": 0, + "totalResults": 1, + "format": "NVD_CVE", + "version": "2.0", + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2026-111357", + "sourceIdentifier": "cve@mitre.org", + "published": "2026-10-15T21:17:13.190", + "lastModified": "2026-10-31T04:18:16.040", + "vulnStatus": "Analyzed", + "cisaExploitAdd": "2026-10-30", + "cisaActionDue": "2026-11-02", + "cisaRequiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (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.", + "cisaVulnerabilityName": "Ivanti Connect Secure Missing Authentication for Critical Function Vulnerability", + "descriptions": [ + { + "lang": "en", + "value": "Ivanti Connect Secure 22.8R2.4 mishandles the manner in which the dsinvoked broker trusts DSCache configuration data, leading to privilege escalation to root, as exploited in the wild in October 2026." + } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "cve@mitre.org", + "type": "Secondary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 7.0, + "baseSeverity": "HIGH" + } + } + ] + }, + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:*:*:*:*:*:*:*:*", + "versionStartIncluding": "25.1.0.0", + "versionEndExcluding": "25.1.1.3", + "matchCriteriaId": "B04FDDB3-6879-4C9B-AB2D-6A54884797E0" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2:*:*:*:*:*:*", + "matchCriteriaId": "56FEC223-8FF7-4A05-9577-CFFE0AA6526C" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.1:*:*:*:*:*:*", + "matchCriteriaId": "5569D323-6ADF-4B16-822D-FBA6760356C3" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.2:*:*:*:*:*:*", + "matchCriteriaId": "FF4D400F-49DA-4C5F-8F63-5A15738360AE" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.3:*:*:*:*:*:*", + "matchCriteriaId": "9CA7EF35-D0BC-4714-A38A-91660BE1BA55" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.4:*:*:*:*:*:*", + "matchCriteriaId": "F22EC396-A447-4175-8A30-C8DC10ACFFE9" + }, + { + "vulnerable": true, + "criteria": "cpe:2.3:a:ivanti:connect_secure:22.8:r2.5:*:*:*:*:*:*", + "matchCriteriaId": "8B173211-BFCF-40E7-8651-4E8C4CEFAFFF" + } + ] + } + ] + } + ], + "references": [ + { + "url": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935", + "source": "cve@mitre.org", + "tags": [ + "Vendor Advisory" + ] + }, + { + "url": "https://www.volexity.com/blog/2026/10/13/a-series-of-unauthenticated-events-chained-exploitation-of-ivanti-connect-secure-zero-days/", + "source": "cve@mitre.org", + "tags": [ + "Third Party Advisory" + ] + }, + { + "url": "https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-111357", + "source": "134c704f-9b21-4f2e-91b3-4a467353bcc0", + "tags": [ + "US Government Resource" + ] + } + ] + } + } + ] +} diff --git a/src/patchorder/engine.py b/src/patchorder/engine.py index 9f70714..b664c70 100644 --- a/src/patchorder/engine.py +++ b/src/patchorder/engine.py @@ -16,7 +16,7 @@ from patchorder.sources import CSAF_PROVIDER, LiveFetcher DEMO_ROOT = Path(__file__).resolve().parent / "data" / "demo" -RECORDED = "2026-10-21" +RECORDED = "2026-10-21 (Ivanti Connect Secure records 2026-11-03)" def utc_now() -> str: @@ -34,7 +34,7 @@ def _load_nvd_dir(path: Path) -> list: return records -def run_demo() -> Report: +def run_demo(as_of: str | None = None) -> Report: root = DEMO_ROOT inventory = load_inventory(root / "inventory.yaml") lock = load_lock(root / "inventory.lock.yaml") @@ -67,6 +67,7 @@ def run_demo() -> Report: previous_ids=previous, warnings=[], sources=sources, + as_of=as_of, ) diff --git a/src/patchorder/gate.py b/src/patchorder/gate.py index 131099a..8d72459 100644 --- a/src/patchorder/gate.py +++ b/src/patchorder/gate.py @@ -21,7 +21,7 @@ def label(self) -> str: def parse_gate(text: str) -> Gate: value = text.strip().lower() - if value in {"kev", "new", "internet"}: + if value in {"kev", "kev-overdue", "new", "internet"}: return Gate(value) if value.startswith("priority:"): try: @@ -31,7 +31,7 @@ def parse_gate(text: str) -> Gate: if not 0 <= threshold <= 105: raise PatchorderError(f"--fail-on {text}: priority is between 0 and 105", code=2) return Gate("priority", threshold) - raise PatchorderError(f"--fail-on {text}: use kev, new, internet, or priority:N", code=2) + raise PatchorderError(f"--fail-on {text}: use kev, kev-overdue, new, internet, or priority:N", code=2) def evaluate_gates(report: Report, gates: list[Gate]) -> list[str]: @@ -40,6 +40,8 @@ def evaluate_gates(report: Report, gates: list[Gate]) -> list[str]: for gate in gates: if gate.kind == "kev": hits = [f for f in report.findings if f.kev] + elif gate.kind == "kev-overdue": + hits = [f for f in report.findings if f.kev and f.kev_overdue_days] elif gate.kind == "internet": hits = [f for f in report.findings if f.exposure == "internet"] elif gate.kind == "priority": diff --git a/src/patchorder/models.py b/src/patchorder/models.py index 337d582..c68b1ad 100644 --- a/src/patchorder/models.py +++ b/src/patchorder/models.py @@ -62,6 +62,7 @@ class Record: published: str | None = None cisa_exploit_add: str | None = None cisa_name: str | None = None + cisa_action_due: str | None = None source: str = "nvd" @@ -80,6 +81,7 @@ class KevEntry: date_added: str | None short_description: str ransomware: str | None = None + due_date: str | None = None @dataclass diff --git a/src/patchorder/records.py b/src/patchorder/records.py index a44d0c6..366745e 100644 --- a/src/patchorder/records.py +++ b/src/patchorder/records.py @@ -89,6 +89,7 @@ def from_nvd_cve(cve: dict, source: str = "nvd") -> Record | None: published=cve.get("published"), cisa_exploit_add=cve.get("cisaExploitAdd"), cisa_name=cve.get("cisaVulnerabilityName"), + cisa_action_due=cve.get("cisaActionDue") if isinstance(cve.get("cisaActionDue"), str) else None, source=source, ) @@ -232,6 +233,7 @@ def parse_kev(payload: dict) -> KevCatalog: date_added=item.get("dateAdded"), short_description=str(item.get("shortDescription") or ""), ransomware=item.get("knownRansomwareCampaignUse"), + due_date=item.get("dueDate") if isinstance(item.get("dueDate"), str) else None, ) published = payload.get("count") note = "" diff --git a/src/patchorder/report.py b/src/patchorder/report.py index 8cdeb83..581c5b0 100644 --- a/src/patchorder/report.py +++ b/src/patchorder/report.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import date from patchorder.csaf import CsafAdvisory, CsafHit, hits_for from patchorder.matching import Observation, evaluate @@ -41,6 +42,8 @@ class Finding: csaf: list[CsafHit] published: str | None basis: str + kev_due_date: str | None = None + kev_overdue_days: int | None = None @dataclass @@ -68,6 +71,7 @@ class Report: formula: tuple[str, ...] = field(default_factory=lambda: FORMULA_LINES) llm_summary: str | None = None llm_preview: str | None = None + as_of: str | None = None def _links(cve_id: str, references: list[str], csaf_hits: list[CsafHit], kev: bool) -> list[tuple[str, str]]: @@ -97,6 +101,25 @@ def _fixed_hint(observation: Observation, csaf_hits: list[CsafHit]) -> str | Non if not pieces: return None return " ".join(pieces) +def _parse_day(value: str | None) -> date | None: + if not value: + return None + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None + + +def overdue_days(due: str | None, as_of: str | None) -> int | None: + """Days past the KEV dueDate on the as-of day. 0 means due today or not yet due. + + None means there is no usable due date, so nothing is claimed. + """ + due_day = _parse_day(due) + as_of_day = _parse_day(as_of) + if due_day is None or as_of_day is None: + return None + return max(0, (as_of_day - due_day).days) def assemble( @@ -112,7 +135,9 @@ def assemble( previous_ids: set[str] | None, warnings: list[str], sources: list[str], + as_of: str | None = None, ) -> Report: + as_of = (as_of or generated_at)[:10] findings: list[Finding] = [] notes: list[AssetNote] = [] for resolution in resolutions: @@ -143,16 +168,19 @@ def assemble( kev_detail = f" (added {kev_entry.date_added})" if kev_entry.date_added else "" kev_name = kev_entry.name kev_date = kev_entry.date_added + kev_due = kev_entry.due_date or record.cisa_action_due ransomware = kev_entry.ransomware elif record.cisa_exploit_add: kev_detail = f" (NVD cisaExploitAdd {record.cisa_exploit_add}; KEV catalog file not loaded)" kev_name = record.cisa_name kev_date = record.cisa_exploit_add + kev_due = record.cisa_action_due ransomware = None else: kev_detail = "" kev_name = None kev_date = None + kev_due = None ransomware = None epss_score = epss.get(record.cve_id) exact = any(hit.known_affected and hit.version_specific for hit in csaf_hits) @@ -198,6 +226,8 @@ def assemble( csaf=csaf_hits, published=record.published, basis=observation.basis, + kev_due_date=kev_due, + kev_overdue_days=overdue_days(kev_due, as_of), ) ) elif observation.verdict == "not_decided": @@ -249,4 +279,5 @@ def assemble( previous_state_loaded=previous_ids is not None, warnings=warnings, sources=sources, + as_of=as_of, ) diff --git a/src/patchorder/versions.py b/src/patchorder/versions.py index 253010b..e566e21 100644 --- a/src/patchorder/versions.py +++ b/src/patchorder/versions.py @@ -2,7 +2,8 @@ Windows builds are compared as dotted integers (10.0.22631.4037). An OpenSSH portable suffix such as 9.8p1 sorts after 9.8. An OpenSSL letter suffix such as -1.1.1w sorts after 1.1.1 and before 1.1.1x. Missing trailing numeric components +1.1.1w sorts after 1.1.1 and before 1.1.1x. A release suffix such as +22.8R2.4 (Ivanti Connect Secure) is split into version 22.8 and CPE update r2.4. Missing trailing numeric components compare as zero, so 1.25 and 1.25.0 are equal. """ @@ -13,6 +14,7 @@ _TOKEN = re.compile(r"\d+|[A-Za-z]+") _LEADING_V = re.compile(r"^[vV](?=\d)") _PORTABLE = re.compile(r"^(.+?)p(\d+)$") +_RELEASE = re.compile(r"^(\d+(?:\.\d+)+)[Rr](\d+(?:\.\d+)*)$") _WINDOWS_BUILD = re.compile(r"^(?:10|6)\.\d+\.\d+(?:\.\d+)?$") # A Windows product whose NVD ranges are build numbers, not marketing names. @@ -46,8 +48,11 @@ def compare_versions(left: str, right: str) -> int: def split_portable_update(version: str) -> tuple[str, str | None]: - """Split an OpenSSH-style 9.8p1 into ('9.8', 'p1'). Leave 1.1.1w intact.""" + """Split 9.8p1 into ('9.8', 'p1') and 22.8R2.4 into ('22.8', 'r2.4'). Leave 1.1.1w intact.""" text = version.strip() + release = _RELEASE.fullmatch(text) + if release: + return release.group(1), "r" + release.group(2) match = _PORTABLE.fullmatch(text) if match and re.search(r"\d", match.group(1)): return match.group(1), "p" + match.group(2) diff --git a/tests/test_demo_and_cli.py b/tests/test_demo_and_cli.py index 73dff51..3a28228 100644 --- a/tests/test_demo_and_cli.py +++ b/tests/test_demo_and_cli.py @@ -34,7 +34,7 @@ def test_demo_briefing_answers_the_question(): assert "bastion-fixed" not in by_asset assert "server-name-only" not in by_asset assert "custom-connector" not in by_asset - assert report.new_cve_ids == ["CVE-2023-44487", "CVE-2024-38063"] + assert report.new_cve_ids == ["CVE-2023-44487", "CVE-2024-38063", "CVE-2026-104935", "CVE-2026-111357"] kinds = {(note.asset_id, note.kind) for note in report.notes} assert ("edge-current", "not_affected") in kinds assert ("file-servers-patched", "not_affected") in kinds diff --git a/tests/test_inventory_csaf_llm.py b/tests/test_inventory_csaf_llm.py index 307ca59..beaf660 100644 --- a/tests/test_inventory_csaf_llm.py +++ b/tests/test_inventory_csaf_llm.py @@ -64,8 +64,8 @@ def test_provider_metadata_and_kev_subset_and_cpe_dictionary(): assert meta["canonical_url"].startswith("https://example.org/") kev = parse_kev(json.loads((ROOT / "kev.json").read_text(encoding="utf-8"))) assert "CVE-2023-44487" in kev.entries - assert kev.published_count == 1754 - assert len(kev.entries) == 1 + assert kev.published_count == 1766 + assert len(kev.entries) == 3 assert "subset" in kev.note or "published catalog count" in kev.note hits = parse_nvd_cpe_hits(json.loads((ROOT / "nvd-cpes-nginx.json").read_text(encoding="utf-8"))) assert any(item["cpe"].startswith("cpe:2.3:a:f5:nginx:") and not item["deprecated"] for item in hits) diff --git a/tests/test_kev_due_date.py b/tests/test_kev_due_date.py new file mode 100644 index 0000000..3612afc --- /dev/null +++ b/tests/test_kev_due_date.py @@ -0,0 +1,96 @@ +"""KEV dueDate handling and the fictional Ivanti Connect Secure demo rows.""" + +from __future__ import annotations + +import csv +import io +import json + +from patchorder.briefing import render_json, render_markdown +from patchorder.cpe import with_version +from patchorder.csvout import render_csv +from patchorder.engine import run_demo +from patchorder.gate import evaluate_gates, parse_gate +from patchorder.records import from_nvd_cve, parse_kev +from patchorder.report import overdue_days +from patchorder.versions import split_portable_update + + +def _by_asset(report): + found = {} + for finding in report.findings: + found.setdefault(finding.asset_id, {})[finding.cve_id] = finding + return found + + +def test_release_suffix_is_split_like_a_cpe_update(): + assert split_portable_update("22.8R2.4") == ("22.8", "r2.4") + assert split_portable_update("22.8r2") == ("22.8", "r2") + assert split_portable_update("9.8p1") == ("9.8", "p1") + assert split_portable_update("1.1.1w") == ("1.1.1w", None) + assert split_portable_update("25.1.1.3") == ("25.1.1.3", None) + assert with_version("cpe:2.3:a:ivanti:connect_secure", "22.8R2.4") == ( + "cpe:2.3:a:ivanti:connect_secure:22.8:*:*:*:*:*:*:*" + ) + + +def test_overdue_days(): + assert overdue_days("2026-11-02", "2026-11-03") == 1 + assert overdue_days("2026-10-16", "2026-11-03T07:00:00+00:00") == 18 + assert overdue_days("2026-11-13", "2026-11-03") == 0 + assert overdue_days("2026-11-03", "2026-11-03") == 0 + assert overdue_days(None, "2026-11-03") is None + assert overdue_days("not a date", "2026-11-03") is None + + +def test_kev_and_nvd_due_dates_are_parsed(): + kev = parse_kev({"count": 1, "vulnerabilities": [{"cveID": "CVE-2026-104935", "dueDate": "2026-10-16"}]}) + assert kev.entries["CVE-2026-104935"].due_date == "2026-10-16" + record = from_nvd_cve({"id": "CVE-2026-104935", "cisaExploitAdd": "2026-10-13", "cisaActionDue": "2026-10-16"}) + assert record.cisa_action_due == "2026-10-16" + + +def test_demo_vpn_rows_match_by_release_and_carry_due_dates(): + report = run_demo(as_of="2026-11-03") + found = _by_asset(report) + gateway = found["vpn-gateway"] + assert set(gateway) == {"CVE-2026-104935", "CVE-2026-111357"} + assert gateway["CVE-2026-104935"].kev_due_date == "2026-10-16" + assert gateway["CVE-2026-104935"].kev_overdue_days == 18 + assert gateway["CVE-2026-111357"].kev_due_date == "2026-11-02" + assert gateway["CVE-2026-111357"].kev_overdue_days == 1 + # NVD lists 22.8 r2.5 for CVE-2026-111357 only, so the lab row has one finding. + assert set(found["vpn-gateway-lab"]) == {"CVE-2026-111357"} + kinds = {(note.asset_id, note.kind) for note in report.notes if "CVE-2026-104935" in note.text} + assert ("vpn-gateway-lab", "not_affected") in kinds + # The due date does not change points. + assert all("due" not in part.text for part in gateway["CVE-2026-104935"].parts) + + +def test_kev_overdue_gate_depends_on_the_as_of_day(): + early = run_demo(as_of="2026-10-20") + line = evaluate_gates(early, [parse_gate("kev-overdue")])[0] + assert line.startswith("--fail-on kev-overdue: 2 finding(s)") + assert "vpn-gateway:CVE-2026-104935" in line + assert "CVE-2026-111357" not in line + late = run_demo(as_of="2026-11-03") + assert "4 finding(s)" in evaluate_gates(late, [parse_gate("kev-overdue")])[0] + + +def test_due_dates_in_markdown_json_and_csv(): + report = run_demo(as_of="2026-11-03") + markdown = render_markdown(report) + assert "KEV due date 2026-10-16 passed 18 days before 2026-11-03" in markdown + assert "KEV due date 2026-11-02 passed 1 day before 2026-11-03" in markdown + payload = json.loads(render_json(report)) + assert payload["as_of"] == "2026-11-03" + row = next(f for f in payload["findings"] if f["asset_id"] == "vpn-gateway" and f["cve_id"] == "CVE-2026-104935") + assert row["kev_due_date"] == "2026-10-16" + assert row["kev_overdue_days"] == 18 + rows = list(csv.DictReader(io.StringIO(render_csv(report)))) + lab = next(r for r in rows if r["asset_id"] == "vpn-gateway-lab") + assert lab["kev_due_date"] == "2026-11-02" + assert lab["kev_overdue_days"] == "1" + plain = next(r for r in rows if r["kev"] == "no") + assert plain["kev_due_date"] == "" + assert plain["kev_overdue_days"] == "" diff --git a/tests/test_v02_gate_csv_inventory.py b/tests/test_v02_gate_csv_inventory.py index 763082a..6e65406 100644 --- a/tests/test_v02_gate_csv_inventory.py +++ b/tests/test_v02_gate_csv_inventory.py @@ -28,10 +28,10 @@ def test_parse_gate(): def test_gates_on_demo(): report = run_demo() - assert evaluate_gates(report, [parse_gate("kev")])[0].startswith("--fail-on kev: 1 finding(s) (edge-proxy:CVE-2023-44487") + assert evaluate_gates(report, [parse_gate("kev")])[0].startswith("--fail-on kev: 4 finding(s) (edge-proxy:CVE-2023-44487, vpn-gateway-lab:CVE-2026-111357") assert evaluate_gates(report, [parse_gate("priority:95")]) == [] assert len(evaluate_gates(report, [parse_gate("priority:40"), parse_gate("internet")])) == 2 - assert "3 finding(s)" in evaluate_gates(report, [parse_gate("new")])[0] + assert "6 finding(s)" in evaluate_gates(report, [parse_gate("new")])[0] with pytest.raises(PatchorderError): evaluate_gates(replace(report, new_cve_ids=None), [parse_gate("new")])