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.
117 lines
4.9 KiB
Python
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.")
|