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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/sca/osv_client.py
ADDED
|
@@ -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
|