narvy-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,617 @@
1
+ """OSV.dev client for SCA: version-range CVE lookups with a local SQLite cache
2
+ and offline fallback."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import requests
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Overridable for airgapped or mirrored deployments.
22
+ OSV_API_URL = os.environ.get("OSV_API_URL", "https://api.osv.dev/v1/query")
23
+ OSV_BATCH_URL = os.environ.get("OSV_BATCH_API_URL", "https://api.osv.dev/v1/querybatch")
24
+
25
+ _DEFAULT_CACHE_PATH = Path.home() / ".narvy" / "osv_cache.db"
26
+
27
+ DEFAULT_TTL_SECONDS = 7 * 24 * 3600
28
+
29
+ # CVE ids older than modern mobile package managers are noise.
30
+ MIN_CVE_YEAR = 2010
31
+
32
+ # OSV has no "CocoaPods" ecosystem (rejected as invalid); Swift packages live
33
+ # under "SwiftURL", keyed by the bare `github.com/<owner>/<repo>` path.
34
+ ECOSYSTEMS = {
35
+ "Maven",
36
+ "PyPI",
37
+ "npm",
38
+ "Go",
39
+ "RubyGems",
40
+ "crates.io",
41
+ "Packagist",
42
+ "NuGet",
43
+ "SwiftURL",
44
+ }
45
+
46
+ _SEVERITY_BUCKETS = [
47
+ (9.0, "CRITICAL"),
48
+ (7.0, "HIGH"),
49
+ (4.0, "MEDIUM"),
50
+ (0.1, "LOW"),
51
+ (0.0, "INFO"),
52
+ ]
53
+
54
+
55
+ @dataclass
56
+ class OSVFinding:
57
+ """Normalized OSV vulnerability, ready to merge into the CLI finding schema."""
58
+
59
+ rule_id: str = "SCA-VULNERABLE-LIBRARY"
60
+ title: str = ""
61
+ cve: str = ""
62
+ osv_id: str = ""
63
+ cwe: str = "CWE-1104"
64
+ severity: str = "MEDIUM"
65
+ cvss_score: float = 0.0
66
+ summary: str = ""
67
+ details: str = ""
68
+ fixed_version: Optional[str] = None
69
+ references: List[str] = field(default_factory=list)
70
+ aliases: List[str] = field(default_factory=list)
71
+ published: str = ""
72
+ modified: str = ""
73
+ package_name: str = ""
74
+ package_version: str = ""
75
+ ecosystem: str = ""
76
+
77
+ def to_dict(self) -> Dict[str, Any]:
78
+ return {
79
+ "rule_id": self.rule_id,
80
+ "title": self.title,
81
+ "cve": self.cve,
82
+ "cve_id": self.cve,
83
+ "osv_id": self.osv_id,
84
+ "cwe": self.cwe,
85
+ "severity": self.severity,
86
+ "cvss_score": self.cvss_score,
87
+ "summary": self.summary,
88
+ "details": self.details,
89
+ "fixed_version": self.fixed_version,
90
+ "recommendation": (
91
+ f"Update {self.package_name} to version {self.fixed_version} or later"
92
+ if self.fixed_version
93
+ else "Update to the latest patched version (no fixed version listed)"
94
+ ),
95
+ "references": self.references,
96
+ "aliases": self.aliases,
97
+ "published": self.published,
98
+ "modified": self.modified,
99
+ "package": self.package_name,
100
+ "version": self.package_version,
101
+ "ecosystem": self.ecosystem,
102
+ "source": "OSV",
103
+ }
104
+
105
+
106
+ class OSVCache:
107
+ """Thread-safe SQLite cache for OSV responses, opened per call."""
108
+
109
+ _SCHEMA = """
110
+ CREATE TABLE IF NOT EXISTS osv_cache (
111
+ package TEXT NOT NULL,
112
+ version TEXT NOT NULL,
113
+ ecosystem TEXT NOT NULL,
114
+ response TEXT NOT NULL,
115
+ cached_at INTEGER NOT NULL,
116
+ PRIMARY KEY (package, version, ecosystem)
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_osv_cache_age ON osv_cache(cached_at);
119
+ """
120
+
121
+ def __init__(self, db_path: Optional[Path] = None, ttl_seconds: int = DEFAULT_TTL_SECONDS):
122
+ self.ttl_seconds = ttl_seconds
123
+ self.db_path = self._resolve_path(db_path)
124
+ self._lock = threading.Lock()
125
+ self._init_db()
126
+
127
+ self.hits = 0
128
+ self.misses = 0
129
+ self.writes = 0
130
+
131
+ @staticmethod
132
+ def _resolve_path(db_path: Optional[Path]) -> Path:
133
+ if db_path:
134
+ db_path = Path(db_path)
135
+ db_path.parent.mkdir(parents=True, exist_ok=True)
136
+ return db_path
137
+ try:
138
+ _DEFAULT_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
139
+ return _DEFAULT_CACHE_PATH
140
+ except (PermissionError, OSError):
141
+ fallback = Path("/tmp/narvy_osv_cache.db")
142
+ fallback.parent.mkdir(parents=True, exist_ok=True)
143
+ return fallback
144
+
145
+ def _connect(self) -> sqlite3.Connection:
146
+ conn = sqlite3.connect(str(self.db_path), timeout=10)
147
+ conn.row_factory = sqlite3.Row
148
+ return conn
149
+
150
+ def _init_db(self) -> None:
151
+ with self._lock, self._connect() as conn:
152
+ conn.executescript(self._SCHEMA)
153
+
154
+ def get(self, package: str, version: str, ecosystem: str) -> Optional[List[Dict[str, Any]]]:
155
+ with self._lock, self._connect() as conn:
156
+ row = conn.execute(
157
+ "SELECT response, cached_at FROM osv_cache "
158
+ "WHERE package = ? AND version = ? AND ecosystem = ?",
159
+ (package, version, ecosystem),
160
+ ).fetchone()
161
+ if not row:
162
+ self.misses += 1
163
+ return None
164
+ if (time.time() - row["cached_at"]) > self.ttl_seconds:
165
+ self.misses += 1
166
+ return None
167
+ try:
168
+ self.hits += 1
169
+ return json.loads(row["response"])
170
+ except json.JSONDecodeError:
171
+ self.misses += 1
172
+ return None
173
+
174
+ def get_stale(self, package: str, version: str, ecosystem: str) -> Optional[List[Dict[str, Any]]]:
175
+ """Cached response ignoring the TTL, for the offline fallback path."""
176
+ with self._lock, self._connect() as conn:
177
+ row = conn.execute(
178
+ "SELECT response FROM osv_cache "
179
+ "WHERE package = ? AND version = ? AND ecosystem = ?",
180
+ (package, version, ecosystem),
181
+ ).fetchone()
182
+ if not row:
183
+ return None
184
+ try:
185
+ return json.loads(row["response"])
186
+ except json.JSONDecodeError:
187
+ return None
188
+
189
+ def put(self, package: str, version: str, ecosystem: str, vulns: List[Dict[str, Any]]) -> None:
190
+ with self._lock, self._connect() as conn:
191
+ conn.execute(
192
+ "INSERT OR REPLACE INTO osv_cache(package, version, ecosystem, "
193
+ "response, cached_at) VALUES (?, ?, ?, ?, ?)",
194
+ (package, version, ecosystem, json.dumps(vulns), int(time.time())),
195
+ )
196
+ conn.commit()
197
+ self.writes += 1
198
+
199
+ def stats(self) -> Dict[str, int]:
200
+ return {"hits": self.hits, "misses": self.misses, "writes": self.writes}
201
+
202
+ def size(self) -> int:
203
+ with self._lock, self._connect() as conn:
204
+ row = conn.execute("SELECT COUNT(*) AS n FROM osv_cache").fetchone()
205
+ return int(row["n"]) if row else 0
206
+
207
+
208
+ def _osv_severity_to_ours(score: float) -> str:
209
+ if score is None:
210
+ return "MEDIUM"
211
+ try:
212
+ score = float(score)
213
+ except (TypeError, ValueError):
214
+ return "MEDIUM"
215
+ for threshold, label in _SEVERITY_BUCKETS:
216
+ if score >= threshold:
217
+ return label
218
+ return "INFO"
219
+
220
+
221
+ def _cvss3_base_score(vector: str) -> float:
222
+ """CVSS v3.x base score from a vector string (FIRST.org formula), 0.0 on error."""
223
+ import math
224
+ try:
225
+ parts = vector.split("/")
226
+ if len(parts) < 2 or not parts[0].startswith("CVSS:3"):
227
+ return 0.0
228
+ metrics: dict = {}
229
+ for p in parts[1:]:
230
+ if ":" in p:
231
+ k, v = p.split(":", 1)
232
+ metrics[k] = v
233
+
234
+ _AV = {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.20}
235
+ _AC = {"L": 0.77, "H": 0.44}
236
+ _PR_U = {"N": 0.85, "L": 0.62, "H": 0.27}
237
+ _PR_C = {"N": 0.85, "L": 0.68, "H": 0.50}
238
+ _UI = {"N": 0.85, "R": 0.62}
239
+ _CIA = {"N": 0.00, "L": 0.22, "H": 0.56}
240
+
241
+ scope = metrics.get("S", "U")
242
+ pr_table = _PR_C if scope == "C" else _PR_U
243
+
244
+ av = _AV.get(metrics.get("AV", ""), None)
245
+ ac = _AC.get(metrics.get("AC", ""), None)
246
+ pr = pr_table.get(metrics.get("PR", ""), None)
247
+ ui = _UI.get(metrics.get("UI", ""), None)
248
+ c = _CIA.get(metrics.get("C", ""), None)
249
+ i = _CIA.get(metrics.get("I", ""), None)
250
+ a = _CIA.get(metrics.get("A", ""), None)
251
+
252
+ if any(x is None for x in (av, ac, pr, ui, c, i, a)):
253
+ return 0.0
254
+
255
+ exploitability = 8.22 * av * ac * pr * ui # type: ignore[operator]
256
+ iss = 1 - ((1 - c) * (1 - i) * (1 - a)) # type: ignore[operator]
257
+
258
+ if scope == "U":
259
+ impact = 6.42 * iss
260
+ else:
261
+ impact = 7.52 * (iss - 0.029) - 3.25 * ((iss - 0.02) ** 15)
262
+
263
+ if impact <= 0:
264
+ return 0.0
265
+
266
+ raw = min(exploitability + impact, 10) if scope == "U" else min(1.08 * (exploitability + impact), 10)
267
+ return math.ceil(raw * 10) / 10
268
+ except Exception:
269
+ return 0.0
270
+
271
+
272
+ def _parse_cvss_score(vector_or_score: Any) -> float:
273
+ if vector_or_score is None:
274
+ return 0.0
275
+ if isinstance(vector_or_score, (int, float)):
276
+ return float(vector_or_score)
277
+ if isinstance(vector_or_score, str):
278
+ try:
279
+ return float(vector_or_score)
280
+ except ValueError:
281
+ pass
282
+ if vector_or_score.startswith("CVSS:3"):
283
+ return _cvss3_base_score(vector_or_score)
284
+ return 0.0
285
+ return 0.0
286
+
287
+
288
+ def _highest_cvss(severity_entries: List[Dict[str, Any]]) -> float:
289
+ best = 0.0
290
+ for entry in severity_entries or []:
291
+ score = _parse_cvss_score(entry.get("score"))
292
+ if score > best:
293
+ best = score
294
+ return best
295
+
296
+
297
+ def _extract_year(cve_id: str) -> Optional[int]:
298
+ if not cve_id or not cve_id.startswith(("CVE-", "GHSA-")):
299
+ return None
300
+ if cve_id.startswith("CVE-"):
301
+ try:
302
+ return int(cve_id.split("-")[1])
303
+ except (IndexError, ValueError):
304
+ return None
305
+ return None
306
+
307
+
308
+ _GIT_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$")
309
+
310
+ _PLATFORM_SUFFIX_RE = re.compile(
311
+ r"-(?:x86_64|x86|aarch64|arm64|arm|amd64|i386|i686|"
312
+ r"java|universal|mingw32|mswin32|"
313
+ r"darwin|linux|linux-gnu|linux-musl|windows|win32|freebsd|solaris)\b.*$",
314
+ re.IGNORECASE,
315
+ )
316
+
317
+
318
+ def _is_git_sha(value: str) -> bool:
319
+ return bool(value) and bool(_GIT_SHA_RE.match(value.strip()))
320
+
321
+
322
+ def _strip_platform_suffix(version: str) -> str:
323
+ if not version:
324
+ return version
325
+ return _PLATFORM_SUFFIX_RE.sub("", version.strip())
326
+
327
+
328
+ def _version_key(version: str):
329
+ core = _strip_platform_suffix(version or "").lstrip("vV")
330
+ m = re.match(r"^([0-9]+(?:\.[0-9]+)*)", core)
331
+ if not m:
332
+ return (0,)
333
+ try:
334
+ return tuple(int(p) for p in m.group(1).split("."))
335
+ except ValueError:
336
+ return (0,)
337
+
338
+
339
+ def _pick_fixed_version(
340
+ raw: Dict[str, Any],
341
+ package_name: str,
342
+ installed_version: str,
343
+ ) -> Optional[str]:
344
+ """Lowest fixed version above the installed one (ECOSYSTEM then SEMVER, never GIT)."""
345
+ inst_key = _version_key(installed_version)
346
+ _TYPE_RANK = {"ECOSYSTEM": 0, "SEMVER": 1}
347
+ candidates = []
348
+ for affected in raw.get("affected", []) or []:
349
+ for r in affected.get("ranges", []) or []:
350
+ rtype = str(r.get("type", "")).upper()
351
+ if rtype == "GIT":
352
+ continue
353
+ rank = _TYPE_RANK.get(rtype, 2)
354
+ for evt in r.get("events", []) or []:
355
+ fv = evt.get("fixed")
356
+ if not fv or _is_git_sha(str(fv)):
357
+ continue
358
+ candidates.append((rank, str(fv)))
359
+
360
+ if not candidates:
361
+ return None
362
+
363
+ best_rank = min(c[0] for c in candidates)
364
+ same_rank = [c[1] for c in candidates if c[0] == best_rank]
365
+ greater = sorted(
366
+ (v for v in same_rank if _version_key(v) > inst_key),
367
+ key=_version_key,
368
+ )
369
+ if greater:
370
+ return greater[0]
371
+ return sorted(same_rank, key=_version_key)[-1]
372
+
373
+
374
+ def _parse_osv_vuln(
375
+ raw: Dict[str, Any],
376
+ package_name: str,
377
+ package_version: str,
378
+ ecosystem: str,
379
+ ) -> Optional[OSVFinding]:
380
+ """One raw OSV vulnerability as an OSVFinding, or None if it is filtered out."""
381
+ osv_id = raw.get("id", "")
382
+ aliases = raw.get("aliases", []) or []
383
+
384
+ cve_id = ""
385
+ for alias in aliases:
386
+ if alias.startswith("CVE-"):
387
+ cve_id = alias
388
+ break
389
+ if not cve_id:
390
+ cve_id = osv_id
391
+
392
+ year = _extract_year(cve_id)
393
+ if year is not None and year < MIN_CVE_YEAR:
394
+ logger.debug(f"OSV: dropping pre-{MIN_CVE_YEAR} CVE {cve_id} on {package_name}@{package_version}")
395
+ return None
396
+
397
+ severity_label = "MEDIUM"
398
+ cvss_score = 0.0
399
+
400
+ sev_arr = raw.get("severity", []) or []
401
+ if sev_arr:
402
+ cvss_score = _highest_cvss(sev_arr)
403
+ if cvss_score > 0:
404
+ severity_label = _osv_severity_to_ours(cvss_score)
405
+
406
+ if cvss_score == 0.0:
407
+ db_specific = raw.get("database_specific", {}) or {}
408
+ sev_str = db_specific.get("severity", "")
409
+ _LABEL_NORM = {"CRITICAL": "CRITICAL", "HIGH": "HIGH", "MODERATE": "MEDIUM",
410
+ "MEDIUM": "MEDIUM", "LOW": "LOW"}
411
+ if isinstance(sev_str, str) and sev_str.upper() in _LABEL_NORM:
412
+ severity_label = _LABEL_NORM[sev_str.upper()]
413
+ cvss_score = {"CRITICAL": 9.5, "HIGH": 7.5, "MEDIUM": 5.0, "LOW": 2.5}[severity_label]
414
+
415
+ if cvss_score == 0.0:
416
+ eco_specific = raw.get("ecosystem_specific", {}) or {}
417
+ sev_str = eco_specific.get("severity", "")
418
+ _LABEL_NORM2 = {"CRITICAL": "CRITICAL", "HIGH": "HIGH", "MODERATE": "MEDIUM",
419
+ "MEDIUM": "MEDIUM", "LOW": "LOW"}
420
+ if isinstance(sev_str, str) and sev_str.upper() in _LABEL_NORM2:
421
+ severity_label = _LABEL_NORM2[sev_str.upper()]
422
+ cvss_score = {"CRITICAL": 9.5, "HIGH": 7.5, "MEDIUM": 5.0, "LOW": 2.5}[severity_label]
423
+
424
+ fixed_version: Optional[str] = _pick_fixed_version(raw, package_name, package_version)
425
+
426
+ refs = []
427
+ for ref in raw.get("references", []) or []:
428
+ url = ref.get("url")
429
+ if url:
430
+ refs.append(url)
431
+ refs = refs[:10]
432
+
433
+ summary = raw.get("summary", "") or ""
434
+ details = raw.get("details", "") or ""
435
+
436
+ title = f"{package_name}@{package_version} affected by {cve_id}"
437
+ if summary:
438
+ title = f"{title}: {summary[:120]}"
439
+
440
+ return OSVFinding(
441
+ rule_id="SCA-VULNERABLE-LIBRARY",
442
+ title=title,
443
+ cve=cve_id,
444
+ osv_id=osv_id,
445
+ severity=severity_label,
446
+ cvss_score=cvss_score,
447
+ summary=summary,
448
+ details=details,
449
+ fixed_version=fixed_version,
450
+ references=refs,
451
+ aliases=aliases,
452
+ published=raw.get("published", ""),
453
+ modified=raw.get("modified", ""),
454
+ package_name=package_name,
455
+ package_version=package_version,
456
+ ecosystem=ecosystem,
457
+ )
458
+
459
+
460
+ class OSVClient:
461
+ """OSV client with cache, offline fallback and a querybatch pre-filter."""
462
+
463
+ def __init__(
464
+ self,
465
+ cache: Optional[OSVCache] = None,
466
+ api_url: str = OSV_API_URL,
467
+ batch_url: str = OSV_BATCH_URL,
468
+ timeout: float = 10.0,
469
+ session: Optional[requests.Session] = None,
470
+ ):
471
+ self.api_url = api_url
472
+ self.batch_url = batch_url
473
+ self.timeout = timeout
474
+ self.cache = cache or OSVCache()
475
+ self._session = session or requests.Session()
476
+ self._session.headers.update(
477
+ {
478
+ "Content-Type": "application/json",
479
+ "User-Agent": "Narvy-CLI-SCA/1.0 (OSV; +https://narvy.io)",
480
+ }
481
+ )
482
+
483
+ def query(
484
+ self,
485
+ package_name: str,
486
+ version: str,
487
+ ecosystem: str = "Maven",
488
+ use_cache: bool = True,
489
+ ) -> List[OSVFinding]:
490
+ """Vulnerabilities for `package_name@version`; falls back to stale cache when offline."""
491
+ if not package_name or not version:
492
+ return []
493
+ if ecosystem not in ECOSYSTEMS:
494
+ logger.warning(f"OSV: unknown ecosystem '{ecosystem}', querying anyway")
495
+
496
+ if use_cache:
497
+ cached = self.cache.get(package_name, version, ecosystem)
498
+ if cached is not None:
499
+ return [
500
+ f
501
+ for f in (
502
+ _parse_osv_vuln(v, package_name, version, ecosystem)
503
+ for v in cached
504
+ )
505
+ if f is not None
506
+ ]
507
+
508
+ body = {
509
+ "package": {"name": package_name, "ecosystem": ecosystem},
510
+ "version": version,
511
+ }
512
+ try:
513
+ resp = self._session.post(self.api_url, json=body, timeout=self.timeout)
514
+ resp.raise_for_status()
515
+ payload = resp.json()
516
+ raw_vulns = payload.get("vulns", []) or []
517
+
518
+ self.cache.put(package_name, version, ecosystem, raw_vulns)
519
+
520
+ findings = [
521
+ f
522
+ for f in (
523
+ _parse_osv_vuln(v, package_name, version, ecosystem)
524
+ for v in raw_vulns
525
+ )
526
+ if f is not None
527
+ ]
528
+ return findings
529
+
530
+ except (requests.RequestException, ValueError, json.JSONDecodeError) as exc:
531
+ logger.warning(f"OSV query failed for {package_name}@{version}: {exc}")
532
+ stale = self.cache.get_stale(package_name, version, ecosystem)
533
+ if stale is not None:
534
+ logger.warning(
535
+ f"OSV: serving STALE cache for {package_name}@{version} "
536
+ f"(network unreachable)"
537
+ )
538
+ return [
539
+ f
540
+ for f in (
541
+ _parse_osv_vuln(v, package_name, version, ecosystem)
542
+ for v in stale
543
+ )
544
+ if f is not None
545
+ ]
546
+ return []
547
+
548
+ def query_dict(self, package_name: str, version: str, ecosystem: str = "Maven") -> List[Dict[str, Any]]:
549
+ # OSV lists one CVE under several advisory ids (GHSA/PYSEC/CVE); dedupe on the CVE.
550
+ out: List[Dict[str, Any]] = []
551
+ seen = set()
552
+ for f in self.query(package_name, version, ecosystem):
553
+ key = getattr(f, "cve", "") or getattr(f, "osv_id", "") or id(f)
554
+ if key in seen:
555
+ continue
556
+ seen.add(key)
557
+ out.append(f.to_dict())
558
+ return out
559
+
560
+ def has_any_vuln_batch(
561
+ self,
562
+ packages: List[tuple],
563
+ ecosystem: str = "Maven",
564
+ batch_size: int = 100,
565
+ ) -> Dict[tuple, bool]:
566
+ """Pre-filter (name, version) tuples through OSV's id-only batch endpoint."""
567
+ result = {pkg: False for pkg in packages}
568
+ if not packages:
569
+ return result
570
+
571
+ # Mark cached packages True so query() still runs (from cache); False would
572
+ # silently drop their CVEs for the whole cache TTL.
573
+ uncached = []
574
+ for pkg in packages:
575
+ if self.cache.get(pkg[0], pkg[1], ecosystem) is not None:
576
+ result[pkg] = True
577
+ else:
578
+ uncached.append(pkg)
579
+ if not uncached:
580
+ return result
581
+
582
+ for i in range(0, len(uncached), batch_size):
583
+ chunk = uncached[i:i + batch_size]
584
+ queries = [
585
+ {"package": {"name": name, "ecosystem": ecosystem}, "version": version}
586
+ for name, version in chunk
587
+ ]
588
+ try:
589
+ resp = self._session.post(self.batch_url, json={"queries": queries}, timeout=self.timeout)
590
+ resp.raise_for_status()
591
+ data = resp.json()
592
+ results = data.get("results", []) or []
593
+ for idx, r in enumerate(results):
594
+ if idx >= len(chunk):
595
+ break
596
+ vulns = r.get("vulns") or []
597
+ if vulns:
598
+ result[chunk[idx]] = True
599
+ except (requests.RequestException, ValueError, json.JSONDecodeError) as exc:
600
+ logger.warning(f"OSV batch query failed (chunk {i}-{i+len(chunk)}): {exc}")
601
+ # Unknown for this chunk: defer to query() rather than under-report.
602
+ for name, version in chunk:
603
+ result[(name, version)] = True
604
+
605
+ return result
606
+
607
+
608
+ _default_client: Optional[OSVClient] = None
609
+ _default_lock = threading.Lock()
610
+
611
+
612
+ def get_default_client() -> OSVClient:
613
+ global _default_client
614
+ with _default_lock:
615
+ if _default_client is None:
616
+ _default_client = OSVClient()
617
+ return _default_client