From d4fa3043c5027c04a70484acfd59fb85603a82ad Mon Sep 17 00:00:00 2001 From: vulncheck Date: Mon, 2 Nov 2026 10:48:44 +0100 Subject: [PATCH] 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. --- README.DEV.md | 17 ++ app/services/app_cve_scanner_service.py | 12 ++ app/services/cvelistv5_scan_service.py | 23 ++- tests/test_dell_supportassist.py | 210 ++++++++++++++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 tests/test_dell_supportassist.py diff --git a/README.DEV.md b/README.DEV.md index bdfbe59..84bc7c5 100644 --- a/README.DEV.md +++ b/README.DEV.md @@ -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, diff --git a/app/services/app_cve_scanner_service.py b/app/services/app_cve_scanner_service.py index 379a529..61eab65 100644 --- a/app/services/app_cve_scanner_service.py +++ b/app/services/app_cve_scanner_service.py @@ -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"(? 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 "")) diff --git a/tests/test_dell_supportassist.py b/tests/test_dell_supportassist.py new file mode 100644 index 0000000..91bdbc0 --- /dev/null +++ b/tests/test_dell_supportassist.py @@ -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")]