fix(scan): drop package rows the scan no longer confirms

A finding is one row per (cve, asset) but lists every affected product, and
two products hit by the same CVE patch on entirely separate schedules.
CVE-2026-17733 hits Chrome and Edge: the tester patched Chrome to
151.0.7922.72 while Edge stayed on 150.0.4078.105, and the finding kept
listing Chrome at its OLD version as still affected. Reading it, you cannot
tell which half is actually outstanding.

The cause is that a package row is only ever written while the product is
detected. Once it is patched, nothing touches the row again, so it keeps
whatever it had — the stale version included. last_seen_at was added for
exactly this and the comment promised a pass that reads it; that pass was
never built. This is it.

Only rows on findings this run re-examined are pruned, and never the last one:
a finding with no packages reads as "we know nothing" rather than "this
product is fixed" — and if it really was the last, the finding itself is stale
and the existing reconcile closes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:14:16 +02:00
co-authored by Claude Opus 5
parent ad85ccd98d
commit b2431ddcea
+48
View File
@@ -716,6 +716,47 @@ def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = No
return count
def prune_stale_packages(db: Session, asset, since: datetime) -> int:
"""Drop per-package rows this run no longer confirmed.
A finding is one row per (cve, asset), but it lists every affected product
— and a CVE can hit two products that patch on completely separate
schedules. CVE-2026-17733 hits Chrome and Edge; the tester patched Chrome
to 151.0.7922.72 while Edge stayed on 150.0.4078.105, and the finding kept
listing Chrome at its OLD version as still affected. The row is only ever
touched while the product is detected, so once it is patched nothing
updates it and it sits there looking unfixed.
last_seen_at was written for this from the start; the pass that reads it
was never built. Only rows on findings this run actually re-examined are
pruned — a finding the scan never reached keeps everything it had.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.vulnerability_package import VulnerabilityPackage
rows = (db.query(VulnerabilityPackage)
.join(Vulnerability,
Vulnerability.id == VulnerabilityPackage.vulnerability_id)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
VulnerabilityPackage.last_seen_at < since)
.all())
dropped = 0
for r in rows:
# Never leave a finding with no packages at all — that reads as "we
# know nothing" instead of "this product is fixed". If it was the last
# one, the finding itself is stale and the reconcile below closes it.
siblings = (db.query(VulnerabilityPackage)
.filter(VulnerabilityPackage.vulnerability_id == r.vulnerability_id)
.count())
if siblings <= 1:
continue
db.delete(r)
dropped += 1
if dropped:
logger.info("pruned %d stale package row(s) on %s", dropped, asset.hostname)
return dropped
def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
"""Reconcile OPEN app-scan findings not re-detected this run: DROP the
app-scan source (same contract as the Nessus backfill), and mark patched
@@ -823,6 +864,9 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
for asset in q.all():
touched = False
touched_cves: set = set() # every CVE re-detected this run → reconcile base
# Marks the start of THIS asset's pass. Package rows not refreshed past
# it were not re-confirmed and get pruned below.
asset_scan_started = datetime.now()
# OS-level CVEs (iOS/iPadOS) — version already in the DB, no client needed.
try:
n = scan_asset_os(db, asset, new_ids, touched=touched_cves)
@@ -945,6 +989,10 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
# agents too, so nothing would ever reopen them.
from app.models.asset import AssetStatus
if asset.status == AssetStatus.ACTIVE:
# Prune first: a finding may stay open on one product while
# another of its products is already patched.
stats["packages_pruned"] = stats.get("packages_pruned", 0) + \
prune_stale_packages(db, asset, asset_scan_started)
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_app_findings(
db, asset, touched_cves)
else: