Files
vulncheck/tests/test_patch_plan.py
T
vulncheck dad8f4e0e0 feat(triage): patch plan ranked by exploited CVEs, with an advisory Jev config hint
The findings list says what is wrong, not what to patch first: one Chrome
update closes a hundred rows, and one KEV on a single host matters more
than all of them. The new /patch-plan page (GET
/api/v1/vulnerabilities/patch-plan) groups open findings (open,
patch_failed) on ACTIVE assets by the product to update, using the
vulnerability_packages rows, else package_name, else the CVE id. Each
group shows hosts, findings, CVEs, the fixed versions as reported (never
compared) and summed/max priority_score. Groups with CVEs exploited in the
wild (KEV / EUVD) rank first, then by summed priority. Every number comes
from data the tool already scores.

TypeSafe Jev (via OpenRouter Decisions, pinned typesafe/jev-1.13) is asked
one question per open CVE, nightly at 06:00 after the last ingest: is it
only exploitable with an optional feature or a non-default configuration?
It is the one triage question no feed answers. KEV, EUVD, EPSS, SSVC and
the CVSS vector already cover exploitation and attack vector, and exposure
is a property of the host, not of the CVE text. The probability is stored
in vulnerabilities.jev_config_dependent (migration 061); CVEs at 0.7 or
above show under "Check config" on the plan. It never closes a finding
(cross-source contract) and never enters priority_score. The job is off by
default (Settings, AI card, or JEV_TRIAGE_ENABLED) and reuses the
OpenRouter key. It sends only public CVE text. An account or network
failure stops the run unstamped; a failure specific to one CVE skips only
that CVE, so the job cannot stall on the same CVE every night.
2026-09-19 09:40:28 +02:00

117 lines
4.9 KiB
Python

"""Patch plan: where to start patching — run: python tests/test_patch_plan.py
The findings list answers "what is wrong"; it does not answer "what do I do
first". One Chrome update closes a hundred rows, one KEV on a single host
outranks all of them. The plan groups open findings by the product that has to
be updated and ranks the groups: exploited-in-the-wild first, then by the
priority the fix removes.
Pinned here: grouping by per-package rows (one CVE hitting two products counts
toward both), only work that is actually open on ACTIVE hosts, exploited
before bulk, and the Jev hint only ever annotates — it never reorders.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
from datetime import datetime # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
import app.models # noqa: F401,E402 (registers every mapper)
from app.models.base import Base # noqa: E402
from app.models.asset import Asset, AssetSource, AssetStatus # noqa: E402
from app.models.vulnerability import ( # noqa: E402
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
)
from app.models.vulnerability_package import VulnerabilityPackage # noqa: E402
from app.services.patch_plan_service import build_patch_plan # noqa: E402
def _db():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def _host(db, name, status=AssetStatus.ACTIVE):
a = Asset(hostname=name, ip_address="10.0.0.1", source=AssetSource.MANUAL, status=status)
db.add(a)
db.flush()
return a
def _vuln(db, asset, cve, pkg, prio, fixed=None, kev=False, status=VulnerabilityStatus.open, **kw):
v = Vulnerability(asset_id=asset.id, cve_id=cve, package_name=pkg, fixed_version=fixed,
priority_score=prio, kev_listed=kev, status=status,
severity=VulnerabilitySeverity.high, detected_at=datetime(2026, 9, 1), **kw)
db.add(v)
db.flush()
return v
def _plan():
db = _db()
hosts = [_host(db, f"host-{i}") for i in range(5)]
gone = _host(db, "gone", AssetStatus.DECOMMISSIONED)
# Chrome on every host: lots of priority, nothing exploited.
for h in hosts:
_vuln(db, h, "CVE-2026-1001", "Google Chrome", 20, fixed="140.0.1")
_vuln(db, h, "CVE-2026-1002", "Google Chrome", 15, fixed="140.0.2")
# One KEV on one host — little summed priority, but exploited.
_vuln(db, hosts[0], "CVE-2026-2001", "OpenSSH", 30, fixed="9.9", kev=True)
# Findings that are not open work must not count.
_vuln(db, hosts[1], "CVE-2026-3001", "7-Zip", 90, status=VulnerabilityStatus.false_positive)
_vuln(db, hosts[1], "CVE-2026-3002", "7-Zip", 90, status=VulnerabilityStatus.accepted_risk)
_vuln(db, gone, "CVE-2026-3003", "7-Zip", 90)
# One CVE, two products on one host (Chrome + Edge) — counts toward both.
both = _vuln(db, hosts[2], "CVE-2026-4001", "Google Chrome", 10, jev_config_dependent=0.9)
db.add(VulnerabilityPackage(vulnerability_id=both.id, package_name="Google Chrome", fixed_version="140.0.3"))
db.add(VulnerabilityPackage(vulnerability_id=both.id, package_name="Microsoft Edge", fixed_version="140.0.9"))
db.commit()
return build_patch_plan(db)
def test_exploited_ranks_before_bulk():
plan = _plan()
assert [g["product"] for g in plan] == ["OpenSSH", "Google Chrome", "Microsoft Edge"], plan
assert plan[0]["exploited_cves"] == ["CVE-2026-2001"]
print("✅ an exploited fix on one host outranks a bulk fix with more summed priority")
def test_group_counts():
chrome = next(g for g in _plan() if g["product"] == "Google Chrome")
assert chrome["hosts"] == 5
assert chrome["findings"] == 11
assert chrome["cve_count"] == 3
assert chrome["priority_sum"] == 5 * 35 + 10
assert chrome["fixed_versions"] == ["140.0.1", "140.0.2", "140.0.3"]
print("✅ hosts, findings, CVEs, priority and fixed versions roll up per product")
def test_closed_and_inactive_are_not_work():
assert "7-Zip" not in [g["product"] for g in _plan()]
print("✅ false positives, accepted risk and decommissioned hosts are not in the plan")
def test_jev_hint_annotates_only():
plan = _plan()
edge = next(g for g in plan if g["product"] == "Microsoft Edge")
assert edge["config_dependent_cves"] == ["CVE-2026-4001"]
chrome = next(g for g in plan if g["product"] == "Google Chrome")
assert chrome["config_dependent_cves"] == ["CVE-2026-4001"]
assert chrome["priority_sum"] == 185 # the hinted finding still counts in full
print("✅ the Jev config hint is listed, the score is untouched")
if __name__ == "__main__":
test_exploited_ranks_before_bulk()
test_group_counts()
test_closed_and_inactive_are_not_work()
test_jev_hint_annotates_only()
print("\nAll patch-plan tests passed.")