feat(app-scan): Dell SupportAssist via NVD and cvelistV5

Intune lists "Dell SupportAssist" 3.14.0.91 (Dell Inc.), and nothing
matched CVE-2024-52535 (symlink attack in the remediation component).
Both sources split it by edition: NVD as dell:supportassist_for_home_pcs
< 4.6.2 and dell:supportassist_for_business_pcs < 4.5.1, cvelistV5 as
"Dell / SupportAssist for Home PCs" and "... for Business PCs".

The editions share version numbers but not bounds, so the name decides:
plain "Dell SupportAssist" is Home, Business registers as "Dell
SupportAssist for Business PCs" (whole_name, so the companion rule keeps
it). Remediation, OS Recovery Plugin and SupportAssistAgent stay out.

Dell's newer records write the floor as "N/A". That reads as a version
which compares to nothing, so the cvelistV5 half never saw them. For the
two SupportAssist keys it is now read as 0, the way NVD reads the same
records; other keys keep the word-floor rule. Index key bumped to v60.
This commit is contained in:
2026-11-02 10:48:44 +01:00
parent c8ced4773a
commit d4fa3043c5
4 changed files with 261 additions and 1 deletions
+17
View File
@@ -2042,6 +2042,23 @@ say which platform its OpenSSL search path is (macOS not checked yet,
FileZilla-style). KeePass Password Safe,
KeePassDX and KeePassXC-Browser are not matched.
**Dell SupportAssist** (`dell:supportassist_for_home_pcs` /
`dell:supportassist_for_business_pcs` on NVD, keys `dell-supportassist-home` /
`dell-supportassist-business` on cvelistV5). Field case 2026-11-02: Intune
lists "Dell SupportAssist" 3.14.0.91 (Dell Inc.) and nothing matched
CVE-2024-52535 (Home `< 4.6.2`, Business `< 4.5.1`). The two editions share
version numbers (Home 3.0 .. 3.14 then 4.x, Business 3.0 .. 3.5 then 4.x) but
not bounds, so the name decides: plain "Dell SupportAssist" is Home, Business
registers as "Dell SupportAssist for Business PCs" (uninstaller listings, not
yet seen in the field; a Business install under the plain name would be judged
by Home bounds). cvelistV5 records say
"SupportAssist for Home PCs" (older: "SupportAssist Client Consumer") and
"... for Business PCs"; the newer ones write the floor as `"N/A"`, which these
two keys read as `0` (NVD reads the same records with no floor). Elsewhere a
word floor still compares to nothing. CVE-2022-34389 ("SupportAssist ", both
editions' bounds in one string) stays on NVD only. Remediation, OS Recovery
Plugin and SupportAssistAgent are not matched.
**UltraVNC** (`uvnc:ultravnc` on NVD, no cvelistV5 row). Field case
2026-10-21: Wazuh and Intune list "UltraVnc" 1.3.8.1 and 1.2.2.2 (uvnc bvba).
NVD holds 37 CVEs, all ranges but one: the Kaspersky batch (CVE-2018-15361,
+12
View File
@@ -116,6 +116,14 @@ GIT_NAME_RE = r"^git(?: for windows)?$"
# KeePassXC-Browser extension number their own releases.
KEEPASSXC_NAME_RE = r"^keepassxc(?: \d+(?:\.\d+)*)?$"
# Dell SupportAssist, shared with the cvelistV5 rows. Two editions, overlapping
# version lines, separate bounds (CVE-2024-52535: Home < 4.6.2, Business
# < 4.5.1): plain "Dell SupportAssist" (3.14.0.91, Dell Inc., in Intune) is the
# Home edition, Business registers under its own name. Anchored: Remediation,
# OS Recovery Plugin and SupportAssistAgent number their own releases.
SUPPORTASSIST_HOME_NAME_RE = r"^dell supportassist(?: for home pcs)?$"
SUPPORTASSIST_BUSINESS_NAME_RE = r"^dell supportassist for business pcs$"
# NVD compares the queried version itself, and reads 5.5.0 as past "up to
# (including) 5.5": SoapUI 5.5.0 got no CVE-2019-12180. A CPE listed here
# (few CVEs) is asked for without a version, and _in_range judges every entry.
@@ -451,6 +459,10 @@ _REGISTRY: List[tuple] = [
# NVD analyses the GitHub CNA's records late; cvelistV5 (key "keepassxc")
# sees them first. Windows only: a distro "keepassxc" hides backports.
_cpe(KEEPASSXC_NAME_RE, "keepassxc:keepassxc"),
# Dell SupportAssist, one CPE per edition, see SUPPORTASSIST_HOME_NAME_RE.
# whole_name: the companion rule would cut "... for Business PCs".
_cpe(SUPPORTASSIST_HOME_NAME_RE, "dell:supportassist_for_home_pcs"),
_cpe(SUPPORTASSIST_BUSINESS_NAME_RE, "dell:supportassist_for_business_pcs", whole_name=True),
_cpe(r"oracle vm virtualbox|virtualbox", "oracle:vm_virtualbox"),
_cpe(r"(?<!\w)gimp(?!\w)", "gimp:gimp"),
# .NET (6/7/8/9) and .NET Framework share the SAME NVD CPE
+22 -1
View File
@@ -44,7 +44,7 @@ _ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_ZIP_MIN_BYTES = 100_000_000 # sanity floor; the real ZIP is ~557 MB
_INDEX_SETTING = "cvelistv5_product_index_v59" # v59: Authenticator for iOS
_INDEX_SETTING = "cvelistv5_product_index_v60" # v60: Dell SupportAssist
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
@@ -458,6 +458,18 @@ _REGISTRY: List[dict] = [
# version ("2.7.11", CVE-2026-4158). Windows hosts only.
{"key": "keepassxc", "re": cpe.KEEPASSXC_NAME_RE,
"pairs": [("keepassxreboot", "keepassxc"), ("keepassxc", "keepassxc")]},
# Dell SupportAssist, same names as the CPE rows, one key per edition.
# Dell writes "SupportAssist for Home PCs" (older: "SupportAssist Client
# Consumer") and "SupportAssist for Business PCs", bound in `lessThan`
# after version "N/A" (CVE-2024-52535). "SupportAssist " with both
# editions' bounds in one string (CVE-2022-34389) is not paired.
# "whole_name": the companion rule would cut "... for Business PCs".
{"key": "dell-supportassist-home", "re": cpe.SUPPORTASSIST_HOME_NAME_RE,
"pairs": [("dell", "supportassist for home pcs"),
("dell", "supportassist client consumer")]},
{"key": "dell-supportassist-business", "re": cpe.SUPPORTASSIST_BUSINESS_NAME_RE,
"whole_name": True,
"pairs": [("dell", "supportassist for business pcs")]},
# LibreOffice. NVD analyses TDF's records late (the 26.2.5 batch,
# CVE-2026-63266 ff., sat at "Received" with no CPE), so this is the path
# that sees the fresh ones. Each record bounds one series ("26.2" ..
@@ -1772,6 +1784,15 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
aff = intel_proset_affected(aff)
if key in ("veeam-br", "veeam-one", "veeam-agent-windows"):
aff = veeam_affected(aff, _KEY_ENTRY[key].get("zero_floor"))
if key.startswith("dell-supportassist-"):
# Dell writes the open floor as "N/A" (CVE-2024-52535:
# "N/A" .. < 4.6.2, "4.6.1 and prior"; NVD: < 4.6.2).
# Elsewhere a word floor compares to nothing, see test_dbeaver.
aff = dict(aff, versions=[
dict(v, version="0") if isinstance(v, dict)
and (v.get("status") or "affected") == "affected"
and str(v.get("version") or "").strip().upper() == "N/A" else v
for v in aff.get("versions") or []])
if key == "aruba-switch" or (key == "aruba-os" and any(
isinstance(v, dict)
and _AOSS_PROSE_RE.search(str(v.get("version") or ""))
+210
View File
@@ -0,0 +1,210 @@
"""Dell SupportAssist — run: venv/bin/python -m pytest tests/test_dell_supportassist.py
Field case, no row matched:
Intune "Dell SupportAssist" 3.14.0.91 (Dell Inc.)
Dell ships two editions with overlapping version lines: Home PCs (3.0 .. 3.14,
then 4.x) and Business PCs (3.0 .. 3.5, then 4.x). The bounds differ per
edition (CVE-2024-52535: Home < 4.6.2, Business < 4.5.1), so the name decides:
plain "Dell SupportAssist" is the Home edition, the Business edition shows as
"Dell SupportAssist for Business PCs".
NVD dell:supportassist_for_home_pcs / dell:supportassist_for_business_pcs
cvelistV5 "Dell / SupportAssist for Home PCs" (older: "SupportAssist Client
Consumer") and "Dell / SupportAssist for Business PCs"
Fixtures are the real NVD cpeMatch entries and cvelistV5 blocks, trimmed.
"""
import json
import os
import sys
import tempfile
import zipfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import app_cve_scanner_service as cpe # noqa: E402
from app.services import cvelistv5_scan_service as c5 # noqa: E402
HOME = "cpe:2.3:a:dell:supportassist_for_home_pcs"
BIZ = "cpe:2.3:a:dell:supportassist_for_business_pcs"
NAME = "Dell SupportAssist"
BIZ_NAME = "Dell SupportAssist for Business PCs"
WIN = "Microsoft Windows 11 Enterprise"
def _m(base, **bounds):
return dict(criteria=base + ":*:*:*:*:*:*:*:*", vulnerable=True, **bounds)
def _rec(cve, *matches):
return {"cve": {"id": cve, "descriptions": [{"lang": "en", "value": cve}],
"configurations": [{"nodes": [{"operator": "OR", "cpeMatch": list(matches)}]}]}}
NVD = {
HOME: [
_rec("CVE-2023-25535", _m(HOME, versionEndExcluding="3.13.2.19")),
_rec("CVE-2023-44283", _m(HOME, versionStartIncluding="3.0", versionEndExcluding="3.14.2"),
_m(BIZ, versionStartIncluding="3.0", versionEndExcluding="3.5.0")),
_rec("CVE-2024-52535", _m(BIZ, versionEndExcluding="4.5.1"),
_m(HOME, versionEndExcluding="4.6.2")),
],
BIZ: [
_rec("CVE-2023-44283", _m(HOME, versionStartIncluding="3.0", versionEndExcluding="3.14.2"),
_m(BIZ, versionStartIncluding="3.0", versionEndExcluding="3.5.0")),
_rec("CVE-2024-52535", _m(BIZ, versionEndExcluding="4.5.1"),
_m(HOME, versionEndExcluding="4.6.2")),
],
}
class _Asset:
id = 1
def __init__(self, operating_system=WIN):
self.operating_system = operating_system
class _Resp:
status_code = 200
def __init__(self, body):
self._body = body
def json(self):
return self._body
def _scan(monkeypatch, version, name=NAME):
"""The real NVD query and scan; only HTTP and the cache are faked."""
asked = []
class Client:
def __init__(self, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, headers=None):
vms = url.split("virtualMatchString=")[1].split("&")[0]
asked.append(vms)
recs = next((r for base, r in NVD.items() if vms.startswith(base + ":")), [])
return _Resp({"vulnerabilities": recs, "totalResults": len(recs)})
got = []
monkeypatch.setattr(cpe.httpx, "Client", Client)
monkeypatch.setattr(cpe.time, "sleep", lambda s: None)
monkeypatch.setattr(cpe, "_cache_get", lambda *a: None)
monkeypatch.setattr(cpe, "_cache_put", lambda *a: None)
monkeypatch.setattr(cpe, "_upsert", lambda db, asset, n, v, c, new_ids, **kw:
got.append((c["cve"], c["fixed"])))
cpe.scan_asset_packages(None, _Asset(), [{"name": name, "version": version,
"vendor": "Dell Inc."}], touched=set())
return sorted(got), asked
def test_resolves_names():
for name in (NAME, "dell supportassist", "Dell SupportAssist for Home PCs"):
assert (cpe.resolve_product(name) or {}).get("cpe") == HOME, name
assert c5.resolve(name) == "dell-supportassist-home", name
assert (cpe.resolve_product(BIZ_NAME) or {}).get("cpe") == BIZ
assert c5.resolve(BIZ_NAME) == "dell-supportassist-business"
# The other SupportAssist parts number their own releases.
for other in ("Dell SupportAssist Remediation", "Dell SupportAssist OS Recovery Plugin for Dell Update",
"Dell SupportAssistAgent", "SupportAssist Recovery Assistant"):
assert (cpe.resolve_product(other) or {}).get("cpe") not in (HOME, BIZ), other
assert not (c5.resolve(other) or "").startswith("dell-supportassist"), other
def test_nvd_home(monkeypatch):
got, asked = _scan(monkeypatch, "3.14.0.91")
assert asked == [HOME + ":3.14.0.91"]
# Each CVE with the Home bound, never the Business one.
assert got == [("CVE-2023-44283", "3.14.2"), ("CVE-2024-52535", "4.6.2")]
# Business 4.5.1 is fixed; Home 4.5.1 is not.
assert _scan(monkeypatch, "4.5.1")[0] == [("CVE-2024-52535", "4.6.2")]
assert _scan(monkeypatch, "4.6.2")[0] == []
def test_nvd_business(monkeypatch):
got, asked = _scan(monkeypatch, "4.5.0", BIZ_NAME)
assert asked == [BIZ + ":4.5.0"]
assert got == [("CVE-2024-52535", "4.5.1")]
assert _scan(monkeypatch, "4.5.1", BIZ_NAME)[0] == []
assert _scan(monkeypatch, "3.4.0", BIZ_NAME)[0] == [
("CVE-2023-44283", "3.5.0"), ("CVE-2024-52535", "4.5.1")]
def _c5(cve, *affected):
return {"cveMetadata": {"cveId": cve}, "containers": {"cna": {"affected": [
{"vendor": "Dell", "product": p, "defaultStatus": "unaffected", "versions": [v]}
for p, v in affected]}}}
def _lt(bound, start="N/A"):
return {"version": start, "lessThan": bound, "versionType": "semver", "status": "affected"}
def _le(bound):
return {"version": "0", "lessThanOrEqual": bound, "versionType": "semver", "status": "affected"}
C5 = [
_c5("CVE-2024-52535", ("SupportAssist for Home PCs", _lt("4.6.2")),
("SupportAssist for Business PCs", _lt("4.5.1"))),
_c5("CVE-2023-44283", ("SupportAssist for Home PCs", _le("3.14.1")),
("SupportAssist for Business PCs", _le("3.4.1"))),
_c5("CVE-2023-25535", ("SupportAssist Client Consumer", _lt("3.13.2.19", "0"))),
_c5("CVE-2025-36612", ("SupportAssist for Business PCs", _lt("4.9.0"))),
# Home and Business bounds in one string, no edition: not paired.
_c5("CVE-2022-34389", ("SupportAssist ", _le("3.11.1, 3.2"))),
]
def _build_index(monkeypatch):
"""Run the REAL index build over a ZIP shaped like cvelistV5's."""
fd, path = tempfile.mkstemp(suffix=".zip")
os.close(fd)
with zipfile.ZipFile(path, "w") as zf:
for rec in C5:
cve = rec["cveMetadata"]["cveId"]
zf.writestr(f"cvelistV5-main/cves/{cve[4:8]}/x/{cve}.json", json.dumps(rec))
monkeypatch.setattr(c5, "_ZIP_PATH", path)
monkeypatch.setattr(c5, "_ensure_zip", lambda force=False: True)
monkeypatch.setattr(c5, "_store_index", lambda db, index: None)
for merge in ("_merge_mozilla_mfsa", "_merge_chrome_releases", "_merge_zoom_bulletins"):
monkeypatch.setattr(c5, merge, lambda *a, **kw: None)
try:
return c5.build_product_index(None)
finally:
os.unlink(path)
def _scan_c5(monkeypatch, index, version, name=NAME):
"""Run the REAL scan_asset, capturing what it would upsert."""
found = []
monkeypatch.setattr(cpe, "_upsert", lambda db, asset, n, v, c, new_ids, **kw: (
found.append((c["cve"], c["fixed"])), new_ids.append(len(new_ids))))
c5.scan_asset(None, _Asset(), [{"name": name, "version": version, "vendor": "Dell Inc."}],
index, touched=set())
return sorted(found)
def test_cvelistv5(monkeypatch):
index = _build_index(monkeypatch)
assert {e["cve"] for e in index["dell-supportassist-home"]} == {
"CVE-2024-52535", "CVE-2023-44283", "CVE-2023-25535"}
assert {e["cve"] for e in index["dell-supportassist-business"]} == {
"CVE-2024-52535", "CVE-2023-44283", "CVE-2025-36612"}
# The field case: Home bounds only.
assert _scan_c5(monkeypatch, index, "3.14.0.91") == [
("CVE-2023-44283", ">3.14.1"), ("CVE-2024-52535", "4.6.2")]
assert _scan_c5(monkeypatch, index, "4.5.1") == [("CVE-2024-52535", "4.6.2")]
assert _scan_c5(monkeypatch, index, "4.6.2") == []
assert _scan_c5(monkeypatch, index, "4.5.0", BIZ_NAME) == [
("CVE-2024-52535", "4.5.1"), ("CVE-2025-36612", "4.9.0")]
assert _scan_c5(monkeypatch, index, "4.5.1", BIZ_NAME) == [("CVE-2025-36612", "4.9.0")]