55become impossible by construction — the model renders what this fetched).
66
77Sources:
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
1216Cache: ~/.dsgai/cve-cache/<ecosystem>/<package>@<version>.json, 24h TTL.
1317`--refresh-cve` ignores the cache; `offline=True` uses cache only (no network).
2529CACHE_TTL = 24 * 3600
2630CACHE_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
4093def _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+
125230def _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 )
0 commit comments