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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user