Skip to content

Commit e0bfa50

Browse files
authored
fix(scanner): CVE correctness — severity, ecosystems, caching (audit H2/H3/M3) (#48)
- H3: severity is computed locally from OSV's own CVSS vector (cvss3_base_score, full v3.1 formula) instead of being discarded. A 9.8 CRITICAL advisory no longer collapses to INFO when NVD is unavailable. NVD is now a fallback only, so classification is deterministic and offline-safe. - H2: add go.mod (Go) and package-lock.json (npm) parsers — those ecosystems got ZERO CVE coverage while the (now-removed) ECOSYSTEMS dict falsely advertised them. .csproj now handles reversed-attr and child-element forms. Verified live: gin 1.6.0 + axios 0.21.0 return 28 real advisories. - M3: don't cache a hollow record when a per-vuln detail fetch fails (was poisoning the 24h cache with INFO); a total OSV failure now warns on stderr instead of silently reading as 'no advisories'. Tests: cvss3_base_score (9.8/5.3/invalid), multi-ecosystem parse incl. go.mod/ package-lock/awkward csproj, langchain live test still green. 23 passed.
1 parent f7002eb commit e0bfa50

2 files changed

Lines changed: 170 additions & 33 deletions

File tree

dsgai_scanner_tool/cli/dsgai_cve.py

Lines changed: 141 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
become impossible by construction — the model renders what this fetched).
66
77
Sources:
8-
- OSV (https://osv.dev) — the only per-version source, via POST /v1/querybatch.
9-
- NVD — used SOLELY to enrich a known CVE id with CVSS via ?cveId= (never
10-
keywordSearch, which returns junk for names like "ai"/"instructor").
8+
- OSV (https://osv.dev) — the per-version source, via POST /v1/querybatch.
9+
Severity/CVSS is computed locally from OSV's own CVSS vector (no network),
10+
so classification is correct and deterministic even offline.
11+
- NVD — a FALLBACK only, used to fetch a CVSS base score for a CVE id
12+
(via ?cveId=, never keywordSearch) when OSV carried no CVSS vector.
13+
14+
Supported manifests carry exact-pinned versions only (see SUPPORTED_MANIFESTS).
1115
1216
Cache: ~/.dsgai/cve-cache/<ecosystem>/<package>@<version>.json, 24h TTL.
1317
`--refresh-cve` ignores the cache; `offline=True` uses cache only (no network).
@@ -25,16 +29,65 @@
2529
CACHE_TTL = 24 * 3600
2630
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".dsgai", "cve-cache")
2731

28-
# Minimal ecosystem detection from manifest filename → OSV ecosystem.
29-
ECOSYSTEMS = {
30-
"requirements.txt": "PyPI", "requirements": "PyPI", "pyproject.toml": "PyPI",
31-
"package.json": "npm", "go.mod": "Go", "Cargo.toml": "crates.io",
32-
"Gemfile.lock": "RubyGems",
33-
}
32+
# Supported manifests → OSV ecosystem. Only formats carrying EXACT pinned
33+
# versions are queryable against OSV (ranges like npm `^1.2` in package.json or
34+
# poetry ranges in pyproject.toml can't be resolved to a concrete version, so
35+
# those are intentionally not listed — use the lockfile instead).
36+
SUPPORTED_MANIFESTS = (
37+
"requirements*.txt (PyPI, == pins), go.mod (Go), package-lock.json (npm), "
38+
"Cargo.lock (crates.io), Gemfile.lock (RubyGems), *.csproj (NuGet)"
39+
)
3440

3541
_REQ_RE = re.compile(r'^\s*([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)')
36-
_CSPROJ_RE = re.compile(r'<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"')
42+
_CSPROJ_ATTR_RE = re.compile(
43+
r'<PackageReference\b[^>]*?\bInclude="([^"]+)"[^>]*?\bVersion="([^"]+)"', re.S)
44+
_CSPROJ_ATTR_REV_RE = re.compile(
45+
r'<PackageReference\b[^>]*?\bVersion="([^"]+)"[^>]*?\bInclude="([^"]+)"', re.S)
46+
_CSPROJ_CHILD_RE = re.compile(
47+
r'<PackageReference\b[^>]*?\bInclude="([^"]+)"[^>]*?>\s*<Version>([^<]+)</Version>', re.S)
3748
_GEMLOCK_RE = re.compile(r'^\s{4}([A-Za-z0-9_.\-]+) \(([0-9][A-Za-z0-9_.\-]*)\)\s*$')
49+
_GOMOD_RE = re.compile(r'^\s*([^\s/][^\s]+/[^\s]+)\s+v(\S+?)(?:\s+//.*)?\s*$')
50+
51+
52+
def _parse_go_mod(ap):
53+
"""go.mod `require` entries (exact versions). Handles single-line and block."""
54+
deps, in_block = [], False
55+
for line in open(ap, encoding="utf-8", errors="replace"):
56+
s = line.strip()
57+
if s.startswith("require ("):
58+
in_block = True
59+
continue
60+
if in_block and s == ")":
61+
in_block = False
62+
continue
63+
text = s[len("require "):].strip() if s.startswith("require ") and "(" not in s else (s if in_block else "")
64+
m = _GOMOD_RE.match(text)
65+
if m and not text.startswith("//"):
66+
deps.append(("Go", m.group(1), m.group(2)))
67+
return deps
68+
69+
70+
def _parse_package_lock(ap):
71+
"""npm package-lock.json (v1 `dependencies` or v2/v3 `packages`), exact versions."""
72+
import json as _json
73+
try:
74+
data = _json.load(open(ap, encoding="utf-8", errors="replace"))
75+
except (ValueError, OSError):
76+
return []
77+
out = []
78+
for path, meta in (data.get("packages") or {}).items():
79+
if path and isinstance(meta, dict) and meta.get("version"):
80+
name = path.split("node_modules/")[-1]
81+
if name:
82+
out.append(("npm", name, meta["version"]))
83+
84+
def walk(deps):
85+
for name, meta in (deps or {}).items():
86+
if isinstance(meta, dict) and meta.get("version"):
87+
out.append(("npm", name, meta["version"]))
88+
walk(meta.get("dependencies"))
89+
walk(data.get("dependencies"))
90+
return out
3891

3992

4093
def _parse_cargo_lock(ap):
@@ -105,8 +158,19 @@ def add(eco, pkg, ver):
105158
m = _GEMLOCK_RE.match(line.rstrip("\n"))
106159
if m:
107160
add("RubyGems", m.group(1), m.group(2))
161+
elif base == "go.mod":
162+
for eco, pkg, ver in _parse_go_mod(ap):
163+
add(eco, pkg, ver)
164+
elif base == "package-lock.json":
165+
for eco, pkg, ver in _parse_package_lock(ap):
166+
add(eco, pkg, ver)
108167
elif base.endswith(".csproj"):
109-
for m in _CSPROJ_RE.finditer(open(ap, encoding="utf-8", errors="replace").read()):
168+
text = open(ap, encoding="utf-8", errors="replace").read()
169+
for m in _CSPROJ_ATTR_RE.finditer(text):
170+
add("NuGet", m.group(1), m.group(2))
171+
for m in _CSPROJ_ATTR_REV_RE.finditer(text):
172+
add("NuGet", m.group(2), m.group(1))
173+
for m in _CSPROJ_CHILD_RE.finditer(text):
110174
add("NuGet", m.group(1), m.group(2))
111175
except OSError:
112176
continue
@@ -122,14 +186,58 @@ def _http_json(url, data=None, timeout=20):
122186
return json.loads(resp.read().decode())
123187

124188

189+
import math
190+
191+
_CVSS3 = {
192+
"AV": {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2},
193+
"AC": {"L": 0.77, "H": 0.44},
194+
"UI": {"N": 0.85, "R": 0.62},
195+
"C": {"H": 0.56, "L": 0.22, "N": 0.0},
196+
"I": {"H": 0.56, "L": 0.22, "N": 0.0},
197+
"A": {"H": 0.56, "L": 0.22, "N": 0.0},
198+
"PR_U": {"N": 0.85, "L": 0.62, "H": 0.27},
199+
"PR_C": {"N": 0.85, "L": 0.68, "H": 0.5},
200+
}
201+
202+
203+
def cvss3_base_score(vector):
204+
"""Compute the CVSS v3.0/3.1 base score (0.0–10.0) from a vector string.
205+
Returns None if the vector is not a parseable CVSS v3 base vector.
206+
207+
Doing this locally means severity no longer depends on a live NVD lookup —
208+
which is what caused critical advisories to collapse to INFO (audit H3) and
209+
made online/offline runs diverge (audit M3)."""
210+
if not vector or not vector.startswith("CVSS:3"):
211+
return None
212+
m = dict(p.split(":", 1) for p in vector.split("/")[1:] if ":" in p)
213+
try:
214+
scope_changed = m.get("S") == "C"
215+
pr = _CVSS3["PR_C" if scope_changed else "PR_U"][m["PR"]]
216+
exploit = 8.22 * _CVSS3["AV"][m["AV"]] * _CVSS3["AC"][m["AC"]] * pr * _CVSS3["UI"][m["UI"]]
217+
iss = 1 - (1 - _CVSS3["C"][m["C"]]) * (1 - _CVSS3["I"][m["I"]]) * (1 - _CVSS3["A"][m["A"]])
218+
if scope_changed:
219+
impact = 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02) ** 15
220+
else:
221+
impact = 6.42 * iss
222+
if impact <= 0:
223+
return 0.0
224+
raw = min((impact + exploit) * (1.08 if scope_changed else 1.0), 10.0)
225+
return math.ceil(raw * 10) / 10.0 # CVSS "roundup" to 1 decimal
226+
except (KeyError, ValueError):
227+
return None
228+
229+
125230
def _severity_of(detail):
126-
"""Extract a coarse severity + CVSS score from an OSV vuln detail."""
231+
"""Return (qualitative label, CVSS base score) from an OSV vuln detail.
232+
233+
Prefers the CVSS score computed locally from OSV's own vector (no network);
234+
falls back to the GHSA database_specific label."""
127235
score = None
128236
for sev in detail.get("severity", []) or []:
129237
if sev.get("type", "").startswith("CVSS"):
130-
# OSV gives a vector; we keep the label from DB-specific fields below.
131-
score = sev.get("score")
132-
# database_specific severity label (GHSA gives HIGH/CRITICAL/…)
238+
score = cvss3_base_score(sev.get("score"))
239+
if score is not None:
240+
break
133241
label = (detail.get("database_specific", {}) or {}).get("severity")
134242
return label, score
135243

@@ -184,22 +292,23 @@ def enrich(discovered, offline=False, refresh=False):
184292
"version": d["version"]} for d in uncached]
185293
batch = _http_json(OSV_BATCH, {"queries": queries})
186294
for d, res in zip(uncached, batch.get("results", [])):
187-
vulns = []
295+
vulns, complete = [], True
188296
for v in res.get("vulns", []) or []:
189297
try:
190298
detail = _http_json(OSV_VULN + v["id"])
191299
except (urllib.error.URLError, ValueError, TimeoutError):
300+
complete = False # don't cache a hollow record (audit M3)
192301
detail = {"id": v["id"]}
193302
aliases = sorted(detail.get("aliases", []) or [])
194-
label, _ = _severity_of(detail)
195-
# NVD enriches CVSS for known CVE ids; stored in cache so
196-
# offline re-runs are byte-identical to the online run.
197-
cvss = None
198-
for aid in [detail.get("id", v["id"])] + aliases:
199-
if aid.startswith("CVE-"):
200-
cvss = enrich_cvss_nvd(aid, offline=False)
201-
if cvss is not None:
202-
break
303+
# Severity from OSV's own CVSS vector (no network); NVD only
304+
# if OSV carried no score AND we have a CVE id to look up.
305+
label, cvss = _severity_of(detail)
306+
if cvss is None:
307+
for aid in [detail.get("id", v["id"])] + aliases:
308+
if aid.startswith("CVE-"):
309+
cvss = enrich_cvss_nvd(aid, offline=False)
310+
if cvss is not None:
311+
break
203312
vulns.append({
204313
"id": detail.get("id", v["id"]),
205314
"aliases": aliases,
@@ -208,10 +317,14 @@ def enrich(discovered, offline=False, refresh=False):
208317
"status": classify(label, cvss),
209318
})
210319
data = {"vulns": vulns}
211-
_cache_put(d["ecosystem"], d["package"], d["version"], data)
320+
if complete: # only cache a fully-resolved package
321+
_cache_put(d["ecosystem"], d["package"], d["version"], data)
212322
fetched[(d["ecosystem"], d["package"].lower(), d["version"])] = data
213-
except (urllib.error.URLError, ValueError, TimeoutError):
214-
pass # network failure → whatever is cached still renders
323+
except (urllib.error.URLError, ValueError, TimeoutError) as exc:
324+
# A total OSV failure must NOT read as "no advisories" (audit M3).
325+
import sys
326+
sys.stderr.write(f"note: CVE enrichment could not reach OSV ({exc}); "
327+
"results reflect cache only and may be incomplete.\n")
215328

216329
merged = dict(cache_map)
217330
merged.update(fetched)

dsgai_scanner_tool/tests/test_runner.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,19 +289,43 @@ def test_semgrep_pack_in_sync():
289289

290290

291291
def test_cve_multi_ecosystem_parse(tmp_path):
292-
"""Offline: manifest parsing recognises NuGet / crates.io / RubyGems deps."""
292+
"""Offline: manifest parsing recognises every supported ecosystem, including
293+
go.mod / package-lock.json (audit H2) and awkward .csproj layouts."""
293294
sys.path.insert(0, os.path.join(SCANNER, "cli"))
294295
import dsgai_cve
295296
(tmp_path / "Cargo.lock").write_text(
296297
'[[package]]\nname = "async-openai"\nversion = "0.18.0"\n', encoding="utf-8")
297298
(tmp_path / "Gemfile.lock").write_text(
298299
"GEM\n specs:\n ruby-openai (6.3.1)\n", encoding="utf-8")
299300
(tmp_path / "app.csproj").write_text(
300-
'<Project><ItemGroup><PackageReference Include="Azure.AI.OpenAI" Version="1.0.0" />'
301+
'<Project><ItemGroup>'
302+
'<PackageReference Version="1.0.0" Include="Azure.AI.OpenAI" />' # reversed attrs
303+
'<PackageReference Include="Child.Pkg"><Version>2.0.0</Version></PackageReference>'
301304
'</ItemGroup></Project>', encoding="utf-8")
302-
disc = [(str(tmp_path / n), n) for n in ("Cargo.lock", "Gemfile.lock", "app.csproj")]
303-
ecos = {d["ecosystem"] for d in dsgai_cve.parse_dependencies(disc)}
304-
assert {"crates.io", "RubyGems", "NuGet"} <= ecos
305+
(tmp_path / "go.mod").write_text(
306+
"module x\nrequire (\n\tgithub.com/foo/bar v1.2.3\n)\n", encoding="utf-8")
307+
(tmp_path / "package-lock.json").write_text(
308+
'{"packages":{"node_modules/axios":{"version":"0.21.0"}}}', encoding="utf-8")
309+
names = ("Cargo.lock", "Gemfile.lock", "app.csproj", "go.mod", "package-lock.json")
310+
disc = [(str(tmp_path / n), n) for n in names]
311+
deps = dsgai_cve.parse_dependencies(disc)
312+
ecos = {d["ecosystem"] for d in deps}
313+
assert {"crates.io", "RubyGems", "NuGet", "Go", "npm"} <= ecos
314+
# both awkward .csproj forms parsed
315+
nuget = {d["package"] for d in deps if d["ecosystem"] == "NuGet"}
316+
assert {"Azure.AI.OpenAI", "Child.Pkg"} <= nuget
317+
318+
319+
def test_cvss3_base_score():
320+
"""CVSS v3.1 base scores computed locally from the OSV vector (audit H3)."""
321+
sys.path.insert(0, os.path.join(SCANNER, "cli"))
322+
import dsgai_cve
323+
assert dsgai_cve.cvss3_base_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") == 9.8
324+
assert dsgai_cve.cvss3_base_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N") == 5.3
325+
assert dsgai_cve.cvss3_base_score("not-a-vector") is None
326+
# a critical advisory with only an OSV vector (NVD unavailable) is EXPLOITABLE,
327+
# not INFO — the bug this fixes.
328+
assert dsgai_cve.classify(None, 9.8) == "EXPLOITABLE"
305329

306330

307331
def test_atlas_map_valid():

0 commit comments

Comments
 (0)