Compare commits
2
Commits
3123263cf0
...
02cfb39486
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02cfb39486 | ||
|
|
d460364590 |
@@ -97,6 +97,37 @@ def _resolve_os(os_name: str) -> Optional[dict]:
|
|||||||
return None
|
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]:
|
def resolve_product(name: str) -> Optional[dict]:
|
||||||
n = (name or "").strip().lower()
|
n = (name or "").strip().lower()
|
||||||
if not n:
|
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"
|
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
|
||||||
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
|
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
|
||||||
items = None
|
items = None
|
||||||
for attempt in range(3):
|
for attempt in range(4):
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=HTTP_TIMEOUT) as c:
|
with httpx.Client(timeout=HTTP_TIMEOUT) as c:
|
||||||
r = c.get(url, headers=headers)
|
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
|
items = [] # permanent (e.g. 404/400) → genuinely empty, cacheable
|
||||||
break
|
break
|
||||||
logger.debug("NVD %s for %s@%s (try %d)", r.status_code, cpe, version, attempt + 1)
|
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)
|
time.sleep(sleep)
|
||||||
if items is None:
|
if items is None:
|
||||||
raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")
|
raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")
|
||||||
@@ -238,20 +270,25 @@ def _query_nvd_cpe(cpe: str, version: str) -> List[dict]:
|
|||||||
continue
|
continue
|
||||||
matched = False
|
matched = False
|
||||||
fixed = None
|
fixed = None
|
||||||
|
tsws: set = set()
|
||||||
for cfg in cve_obj.get("configurations", []) or []:
|
for cfg in cve_obj.get("configurations", []) or []:
|
||||||
for node in cfg.get("nodes", []) or []:
|
for node in cfg.get("nodes", []) or []:
|
||||||
for m in node.get("cpeMatch", []) 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
|
continue
|
||||||
if not m.get("vulnerable", True):
|
if not m.get("vulnerable", True):
|
||||||
continue
|
continue
|
||||||
if _in_range(version, m):
|
if _in_range(version, m):
|
||||||
matched = True
|
matched = True
|
||||||
|
parts = crit.split(":")
|
||||||
|
tsws.add(parts[9] if len(parts) > 9 else "*") # target_sw
|
||||||
fixed = fixed or m.get("versionEndExcluding")
|
fixed = fixed or m.get("versionEndExcluding")
|
||||||
if not matched:
|
if not matched:
|
||||||
continue
|
continue
|
||||||
cvss, sev = _nvd_cvss(cve_obj)
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -300,9 +337,12 @@ def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) ->
|
|||||||
db.commit()
|
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]:
|
def lookup_cves(db: Session, entry: dict, version: str) -> List[dict]:
|
||||||
"""Cached (product, version) → CVE list."""
|
"""Cached (product, version) → CVE list."""
|
||||||
key = entry["key"]
|
key = CACHE_PREFIX + entry["key"]
|
||||||
cached = _cache_get(db, key, version)
|
cached = _cache_get(db, key, version)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
@@ -389,7 +429,10 @@ def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[li
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
|
logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
|
||||||
continue
|
continue
|
||||||
|
plat = _os_family(asset.operating_system or "")
|
||||||
for c in cves:
|
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:
|
try:
|
||||||
before = len(new_ids)
|
before = len(new_ids)
|
||||||
_upsert(db, asset, name, version, c, 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:
|
except Exception as ex:
|
||||||
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
|
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
|
||||||
return 0
|
return 0
|
||||||
|
plat = _os_family(asset.operating_system or "")
|
||||||
count = 0
|
count = 0
|
||||||
for c in cves:
|
for c in cves:
|
||||||
|
if not _platform_ok(c.get("tsw"), plat):
|
||||||
|
continue
|
||||||
before = len(new_ids)
|
before = len(new_ids)
|
||||||
try:
|
try:
|
||||||
_upsert(db, asset, e["label"], asset.os_version or cver, c, new_ids)
|
_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
|
from app.models.asset import Asset, AssetSource
|
||||||
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
|
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
|
||||||
new_ids: list = []
|
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
|
wazuh = None
|
||||||
graph = None
|
graph = None
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function AssetsPage() {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [showInactive, setShowInactive] = useState(false);
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
|
const [sourceFilter, setSourceFilter] = useState('');
|
||||||
// Table sort state
|
// Table sort state
|
||||||
const [sortBy, setSortBy] = useState<string>('hostname');
|
const [sortBy, setSortBy] = useState<string>('hostname');
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
@@ -56,6 +57,7 @@ export default function AssetsPage() {
|
|||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (searchText) params.search = searchText;
|
if (searchText) params.search = searchText;
|
||||||
if (showInactive) params.include_inactive = true;
|
if (showInactive) params.include_inactive = true;
|
||||||
|
if (sourceFilter) params.source = sourceFilter;
|
||||||
params.sort_by = sortBy;
|
params.sort_by = sortBy;
|
||||||
params.sort_order = sortOrder;
|
params.sort_order = sortOrder;
|
||||||
params.limit = pageSize;
|
params.limit = pageSize;
|
||||||
@@ -114,14 +116,14 @@ export default function AssetsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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).
|
// Fetch on page / filter / search change (debounced for typing).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
|
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
@@ -332,6 +334,18 @@ export default function AssetsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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.">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
Reference in New Issue
Block a user