Compare commits

2 Commits
Author SHA1 Message Date
vulncheck 02cfb39486 feat(assets): filter inventory by sync source
Dropdown on the Assets page (All / Wazuh / Nessus / Intune+Defender /
Manual). Backend already accepts ?source=; this just wires the UI.
2026-06-23 10:01:37 +02:00
vulncheck d460364590 fix(app-cve-scan): platform check kills cross-platform false-positives
Desktop Firefox on a Windows host was matching the Firefox-for-iOS CVE
(cpe:2.3:a:mozilla:firefox:*:...:iphone_os:*, target_sw=iphone_os). We
matched on vendor:product only and ignored the CPE platform field.

Now record each matching cpeMatch's target_sw (CPE index 9) and keep a CVE
for an asset only when target_sw is platform-neutral (*) or names the asset's
OS family (_os_family). Applied to both package and OS scans.

Cache key bumped to v2: → pre-fix rows (without target_sw) are ignored so the
stale FPs aren't served from cache; they re-fetch with the platform data.

Also: 503 backoff raised to >=3s × attempt over 4 tries (NVD 2.0 503s under
load even WITH a key — it's server-side, not auth), and the scan logs whether
NVD_API_KEY is present so a missing key is obvious in the logs.
2026-06-23 10:00:02 +02:00
2 changed files with 69 additions and 7 deletions
+53 -5
View File
@@ -97,6 +97,37 @@ def _resolve_os(os_name: str) -> Optional[dict]:
return None
def _os_family(os_name: str) -> Optional[str]:
"""Map an asset OS string to a CPE target_sw token."""
n = (os_name or "").lower()
if "windows" in n:
return "windows"
if "ipad" in n:
return "ipados"
if "iphone" in n or "ios" in n:
return "iphone_os"
if "android" in n:
return "android"
if "mac" in n or "darwin" in n or "os x" in n:
return "macos"
if any(x in n for x in ("linux", "ubuntu", "debian", "centos", "red hat",
"rhel", "fedora", "suse", "alma", "rocky")):
return "linux"
return None
def _platform_ok(tsws, plat: Optional[str]) -> bool:
"""A CVE's matching cpeMatch target_sw must be platform-neutral (*) or
name the asset's OS. Stops desktop Firefox-on-Windows matching the
Firefox-for-iOS CPE (target_sw=iphone_os) and similar cross-platform FPs.
"""
if not tsws:
return True # unknown (e.g. pre-fix cache) → keep
if any(t in ("*", "-", "") for t in tsws):
return True
return bool(plat) and plat in tsws
def resolve_product(name: str) -> Optional[dict]:
n = (name or "").strip().lower()
if not n:
@@ -207,7 +238,7 @@ def _query_nvd_cpe(cpe: str, version: str) -> List[dict]:
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
items = None
for attempt in range(3):
for attempt in range(4):
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as c:
r = c.get(url, headers=headers)
@@ -224,7 +255,8 @@ def _query_nvd_cpe(cpe: str, version: str) -> List[dict]:
items = [] # permanent (e.g. 404/400) → genuinely empty, cacheable
break
logger.debug("NVD %s for %s@%s (try %d)", r.status_code, cpe, version, attempt + 1)
time.sleep(sleep * (attempt + 1))
# NVD 2.0 throws 503 under load even with a key → back off generously.
time.sleep(max(3.0, sleep) * (attempt + 1))
time.sleep(sleep)
if items is None:
raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")
@@ -238,20 +270,25 @@ def _query_nvd_cpe(cpe: str, version: str) -> List[dict]:
continue
matched = False
fixed = None
tsws: set = set()
for cfg in cve_obj.get("configurations", []) or []:
for node in cfg.get("nodes", []) or []:
for m in node.get("cpeMatch", []) or []:
if prod_token not in (m.get("criteria") or ""):
crit = m.get("criteria") or ""
if prod_token not in crit:
continue
if not m.get("vulnerable", True):
continue
if _in_range(version, m):
matched = True
parts = crit.split(":")
tsws.add(parts[9] if len(parts) > 9 else "*") # target_sw
fixed = fixed or m.get("versionEndExcluding")
if not matched:
continue
cvss, sev = _nvd_cvss(cve_obj)
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev, "fixed": fixed})
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
"fixed": fixed, "tsw": sorted(tsws)})
return out
@@ -300,9 +337,12 @@ def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) ->
db.commit()
CACHE_PREFIX = "v2:" # bump → ignores pre-target_sw cache rows (stale FPs)
def lookup_cves(db: Session, entry: dict, version: str) -> List[dict]:
"""Cached (product, version) → CVE list."""
key = entry["key"]
key = CACHE_PREFIX + entry["key"]
cached = _cache_get(db, key, version)
if cached is not None:
return cached
@@ -389,7 +429,10 @@ def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[li
except Exception as e:
logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
continue
plat = _os_family(asset.operating_system or "")
for c in cves:
if not _platform_ok(c.get("tsw"), plat):
continue # CVE is for a different OS platform (e.g. Firefox-iOS)
try:
before = len(new_ids)
_upsert(db, asset, name, version, c, new_ids)
@@ -413,8 +456,11 @@ def scan_asset_os(db: Session, asset, new_ids: list) -> int:
except Exception as ex:
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
return 0
plat = _os_family(asset.operating_system or "")
count = 0
for c in cves:
if not _platform_ok(c.get("tsw"), plat):
continue
before = len(new_ids)
try:
_upsert(db, asset, e["label"], asset.os_version or cver, c, new_ids)
@@ -430,6 +476,8 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
from app.models.asset import Asset, AssetSource
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
new_ids: list = []
logger.info("App CVE scan starting: NVD key %s",
"present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")
wazuh = None
graph = None
+16 -2
View File
@@ -17,6 +17,7 @@ export default function AssetsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [sourceFilter, setSourceFilter] = useState('');
// Table sort state
const [sortBy, setSortBy] = useState<string>('hostname');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
@@ -56,6 +57,7 @@ export default function AssetsPage() {
const params: any = {};
if (searchText) params.search = searchText;
if (showInactive) params.include_inactive = true;
if (sourceFilter) params.source = sourceFilter;
params.sort_by = sortBy;
params.sort_order = sortOrder;
params.limit = pageSize;
@@ -114,14 +116,14 @@ export default function AssetsPage() {
useEffect(() => {
setPage(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText, showInactive, sortBy, sortOrder, pageSize]);
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter]);
// Fetch on page / filter / search change (debounced for typing).
useEffect(() => {
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText]);
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -332,6 +334,18 @@ export default function AssetsPage() {
</svg>
</button>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
title="Filter by the source that first registered the asset"
className="rounded-sm border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm font-mono"
>
<option value="">All sources</option>
<option value="WAZUH">Wazuh</option>
<option value="NESSUS">Nessus</option>
<option value="INTUNE">Intune / Defender</option>
<option value="MANUAL">Manual</option>
</select>
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="INACTIVE assets are always shown (amber badge). Tick to also show operator-retired DECOMMISSIONED assets.">
<input
type="checkbox"