attackmap 0.6.4__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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/cve.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""OSV.dev CVE lookup for SBOM entries (#60, slice 2 of #48).
|
|
2
|
+
|
|
3
|
+
Cross-references every ``DependencyHint`` against the OSV.dev advisory
|
|
4
|
+
database and emits ``Vulnerability`` records. The scanner-level CVE
|
|
5
|
+
lookup is off by default (``--cve`` on the CLI) — it does network I/O
|
|
6
|
+
and doesn't belong in an offline-only scan.
|
|
7
|
+
|
|
8
|
+
## Design summary
|
|
9
|
+
|
|
10
|
+
- **Endpoints**: ``POST https://api.osv.dev/v1/querybatch`` in chunks of
|
|
11
|
+
up to ``_BATCH_SIZE`` (ecosystem, name, version) tuples — one request
|
|
12
|
+
covers hundreds of packages instead of one request per package (a
|
|
13
|
+
lockfile-resolved monorepo can carry thousands of pinned deps; per-
|
|
14
|
+
package queries took ~15 minutes on such repos). The batch response
|
|
15
|
+
carries vulnerability IDs only, so full records are then fetched via
|
|
16
|
+
``GET https://api.osv.dev/v1/vulns/{id}`` — but only for the handful
|
|
17
|
+
of packages that actually have advisories, memoized per run. A batch
|
|
18
|
+
result that paginates (``next_page_token``, rare) falls back to the
|
|
19
|
+
classic ``POST /v1/query`` for that one package.
|
|
20
|
+
- **Concrete version resolution**: OSV needs a concrete version to
|
|
21
|
+
match against affected ranges. Manifests carry range specs
|
|
22
|
+
(``^4.16.0``, ``>=2.28,<3``, ``latest``); we extract a best-effort
|
|
23
|
+
lower-bound and let OSV decide whether that version is affected.
|
|
24
|
+
Repos that pin ranges without a lower bound (``*``, ``latest``) are
|
|
25
|
+
skipped with a note.
|
|
26
|
+
- **Cache**: JSON files under ``~/.attackmap/cache/osv/`` keyed by
|
|
27
|
+
``sha256(ecosystem + name + version)``. TTL is
|
|
28
|
+
``ATTACKMAP_OSV_CACHE_TTL_HOURS`` (default 24). Cache hits stay valid
|
|
29
|
+
offline; misses fall through to the network.
|
|
30
|
+
- **Offline mode**: When the network is unavailable, results from a
|
|
31
|
+
warm cache are still emitted; misses are skipped. A single warning
|
|
32
|
+
goes on stderr.
|
|
33
|
+
- **Rate limiting**: sequential requests with a small sleep between
|
|
34
|
+
calls. OSV has no hard rate limit for anonymous traffic; the cache
|
|
35
|
+
makes repeat scans free.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import time
|
|
46
|
+
import urllib.error
|
|
47
|
+
import urllib.request
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Callable, Iterable
|
|
51
|
+
|
|
52
|
+
from .models import DependencyHint, Vulnerability
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_OSV_QUERY_URL = "https://api.osv.dev/v1/query"
|
|
58
|
+
_OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
|
|
59
|
+
_OSV_VULNS_URL = "https://api.osv.dev/v1/vulns/"
|
|
60
|
+
# OSV accepts up to 1000 queries per batch call; stay comfortably under.
|
|
61
|
+
_BATCH_SIZE = 500
|
|
62
|
+
_OSV_ECOSYSTEM: dict[str, str] = {
|
|
63
|
+
"pypi": "PyPI",
|
|
64
|
+
"npm": "npm",
|
|
65
|
+
"go": "Go",
|
|
66
|
+
"cargo": "crates.io",
|
|
67
|
+
"composer": "Packagist",
|
|
68
|
+
# #255 — names per the OSV schema's ecosystem table.
|
|
69
|
+
"maven": "Maven", # name is groupId:artifactId
|
|
70
|
+
"nuget": "NuGet",
|
|
71
|
+
"gem": "RubyGems",
|
|
72
|
+
"hex": "Hex",
|
|
73
|
+
"pub": "Pub",
|
|
74
|
+
"conan": "ConanCenter",
|
|
75
|
+
"vcpkg": "vcpkg",
|
|
76
|
+
# OSV keys SwiftPM packages by source repository (`github.com/apple/swift-nio`),
|
|
77
|
+
# so the query name is the normalized repo URL, not the package identity.
|
|
78
|
+
"swiftpm": "SwiftURL",
|
|
79
|
+
# Carthage pins git repositories, so a pinned repo is looked up under the
|
|
80
|
+
# same SwiftURL name as when SwiftPM fetches it (#188).
|
|
81
|
+
"carthage": "SwiftURL",
|
|
82
|
+
}
|
|
83
|
+
# Ecosystems with no OSV.dev database: inventoried in the SBOM but never
|
|
84
|
+
# CVE-matched. Reported as such so an empty result isn't read as "no vulns".
|
|
85
|
+
INVENTORY_ONLY_ECOSYSTEMS: frozenset[str] = frozenset({"cocoapods", "cmake"})
|
|
86
|
+
|
|
87
|
+
_DEFAULT_TTL_HOURS = 24
|
|
88
|
+
_REQUEST_TIMEOUT_S = 10
|
|
89
|
+
_INTER_REQUEST_SLEEP_S = 0.1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class LookupSummary:
|
|
94
|
+
"""Structured recap of what happened during a --cve run.
|
|
95
|
+
|
|
96
|
+
Consumers use ``queried`` / ``cached`` / ``skipped_no_version`` /
|
|
97
|
+
``skipped_offline`` to print a single-line summary.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
queried: int = 0
|
|
101
|
+
cached: int = 0
|
|
102
|
+
skipped_no_version: int = 0
|
|
103
|
+
skipped_offline: int = 0
|
|
104
|
+
network_errors: int = 0
|
|
105
|
+
# Dependencies in ecosystems OSV.dev doesn't cover, by ecosystem (#255).
|
|
106
|
+
inventory_only: dict[str, int] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- Public entrypoint -----------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def query_vulnerabilities(
|
|
113
|
+
dependencies: Iterable[DependencyHint],
|
|
114
|
+
*,
|
|
115
|
+
cache_dir: Path | None = None,
|
|
116
|
+
transport: Callable[[str, bytes], bytes] | None = None,
|
|
117
|
+
clock: Callable[[], float] | None = None,
|
|
118
|
+
progress=None,
|
|
119
|
+
) -> tuple[list[Vulnerability], LookupSummary]:
|
|
120
|
+
"""Look up CVEs for each ``DependencyHint`` and return records.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
dependencies
|
|
125
|
+
The SBOM entries to look up. Ecosystems without an OSV mapping
|
|
126
|
+
are skipped; inventory-only ones (CocoaPods) are tallied in
|
|
127
|
+
``LookupSummary.inventory_only``.
|
|
128
|
+
cache_dir
|
|
129
|
+
Overrides the default ``~/.attackmap/cache/osv`` — tests
|
|
130
|
+
supply a per-test tmpdir here.
|
|
131
|
+
transport
|
|
132
|
+
Injectable network transport ``(url, body) -> response_bytes``.
|
|
133
|
+
Tests pass a stub; default is urllib. Detail lookups pass an
|
|
134
|
+
empty body (a GET in the default transport).
|
|
135
|
+
clock
|
|
136
|
+
Injectable ``time.time`` for deterministic cache-TTL tests.
|
|
137
|
+
progress
|
|
138
|
+
Optional duck-typed progress sink with ``begin(total, label)``
|
|
139
|
+
and ``advance(current)`` — the CLI passes its ``ScanProgress``
|
|
140
|
+
so a long lookup renders as a determinate bar, not a stalled
|
|
141
|
+
spinner.
|
|
142
|
+
"""
|
|
143
|
+
cache = _resolve_cache_dir(cache_dir)
|
|
144
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
now = (clock or time.time)()
|
|
146
|
+
ttl_seconds = _ttl_seconds()
|
|
147
|
+
|
|
148
|
+
vulns: list[Vulnerability] = []
|
|
149
|
+
summary = LookupSummary()
|
|
150
|
+
seen_keys: set[tuple[str, str, str, str]] = set()
|
|
151
|
+
|
|
152
|
+
# Pass 1 — resolve each hint to a concrete queryable version and dedupe
|
|
153
|
+
# to unique (ecosystem, name, version) packages. A lockfile monorepo can
|
|
154
|
+
# carry thousands of hints; querying per unique package (batched below)
|
|
155
|
+
# instead of per hint is what keeps --cve tractable.
|
|
156
|
+
entries: list[tuple[DependencyHint, tuple[str, str, str]]] = []
|
|
157
|
+
unique: dict[tuple[str, str, str], tuple[str, str, str]] = {} # key -> (osv_eco, name, concrete)
|
|
158
|
+
for dep in dependencies:
|
|
159
|
+
osv_eco = _OSV_ECOSYSTEM.get(dep.ecosystem)
|
|
160
|
+
if osv_eco is None:
|
|
161
|
+
if dep.ecosystem in INVENTORY_ONLY_ECOSYSTEMS:
|
|
162
|
+
summary.inventory_only[dep.ecosystem] = summary.inventory_only.get(dep.ecosystem, 0) + 1
|
|
163
|
+
continue
|
|
164
|
+
osv_name = osv_package_name(dep)
|
|
165
|
+
if osv_name is None:
|
|
166
|
+
summary.skipped_no_version += 1
|
|
167
|
+
continue
|
|
168
|
+
if dep.resolved and dep.version:
|
|
169
|
+
# Lockfile-resolved versions are already exact — query them
|
|
170
|
+
# verbatim so PEP 440 forms like `1.0.post1` / `1!2.3.0` aren't
|
|
171
|
+
# mangled by range normalization (#143). Only strip Go's `v`.
|
|
172
|
+
concrete = dep.version.lstrip("v") if dep.ecosystem == "go" else dep.version
|
|
173
|
+
else:
|
|
174
|
+
concrete = resolve_concrete_version(dep.version)
|
|
175
|
+
if concrete is None:
|
|
176
|
+
summary.skipped_no_version += 1
|
|
177
|
+
continue
|
|
178
|
+
key = (dep.ecosystem, osv_name, concrete)
|
|
179
|
+
entries.append((dep, key))
|
|
180
|
+
unique.setdefault(key, (osv_eco, osv_name, concrete))
|
|
181
|
+
|
|
182
|
+
if progress is not None:
|
|
183
|
+
progress.begin(len(unique), label="Checking dependencies against OSV.dev")
|
|
184
|
+
|
|
185
|
+
# Pass 2 — serve unique packages from the cache where fresh.
|
|
186
|
+
payload_by_key: dict[tuple[str, str, str], dict] = {}
|
|
187
|
+
for key in unique:
|
|
188
|
+
cache_path = cache / (_cache_key(*key) + ".json")
|
|
189
|
+
payload = _read_cache(cache_path, now, ttl_seconds)
|
|
190
|
+
if payload is not None:
|
|
191
|
+
payload_by_key[key] = payload
|
|
192
|
+
summary.cached += 1
|
|
193
|
+
if progress is not None:
|
|
194
|
+
progress.advance(key[1])
|
|
195
|
+
|
|
196
|
+
# Pass 3 — batch-query the misses (one POST per _BATCH_SIZE packages),
|
|
197
|
+
# then fetch full advisory records only for the IDs that actually hit,
|
|
198
|
+
# memoized so a CVE shared by many packages is fetched once.
|
|
199
|
+
misses = [key for key in unique if key not in payload_by_key]
|
|
200
|
+
details_memo: dict[str, dict] = {}
|
|
201
|
+
for start in range(0, len(misses), _BATCH_SIZE):
|
|
202
|
+
chunk = misses[start : start + _BATCH_SIZE]
|
|
203
|
+
results = _fetch_osv_batch([unique[key] for key in chunk], transport, summary)
|
|
204
|
+
if results is None:
|
|
205
|
+
# Whole batch unreachable — same offline semantics as before,
|
|
206
|
+
# tallied per package so the summary line stays meaningful.
|
|
207
|
+
summary.skipped_offline += len(chunk)
|
|
208
|
+
if progress is not None:
|
|
209
|
+
for key in chunk:
|
|
210
|
+
progress.advance(key[1])
|
|
211
|
+
continue
|
|
212
|
+
for key, result in zip(chunk, results):
|
|
213
|
+
osv_eco, name, concrete = unique[key]
|
|
214
|
+
payload: dict | None
|
|
215
|
+
if not isinstance(result, dict):
|
|
216
|
+
result = {}
|
|
217
|
+
if result.get("next_page_token"):
|
|
218
|
+
# A package with >1 page of advisories (rare): fall back to
|
|
219
|
+
# the classic single query, following ITS pagination too —
|
|
220
|
+
# exactly these high-advisory packages paginate there as well.
|
|
221
|
+
payload = _fetch_osv_paged(osv_eco, name, concrete, transport, summary)
|
|
222
|
+
else:
|
|
223
|
+
# Assemble the full advisory records for this package. If any
|
|
224
|
+
# detail fetch fails, the package must NOT be cached — a
|
|
225
|
+
# partial payload would suppress a known vulnerability for the
|
|
226
|
+
# whole cache TTL. Treat it as offline for this run instead.
|
|
227
|
+
payload = _assemble_details(result, details_memo, transport, summary)
|
|
228
|
+
if payload is None:
|
|
229
|
+
summary.skipped_offline += 1
|
|
230
|
+
if progress is not None:
|
|
231
|
+
progress.advance(name)
|
|
232
|
+
continue
|
|
233
|
+
payload_by_key[key] = payload
|
|
234
|
+
_write_cache(cache / (_cache_key(*key) + ".json"), payload, now)
|
|
235
|
+
summary.queried += 1
|
|
236
|
+
if progress is not None:
|
|
237
|
+
progress.advance(name)
|
|
238
|
+
if transport is None and start + _BATCH_SIZE < len(misses):
|
|
239
|
+
time.sleep(_INTER_REQUEST_SLEEP_S)
|
|
240
|
+
|
|
241
|
+
# Pass 4 — fan the per-package payloads back out to every hint (dedup by
|
|
242
|
+
# (eco, name, version, id) keeps multi-lockfile repeats from double-emitting).
|
|
243
|
+
for dep, key in entries:
|
|
244
|
+
payload = payload_by_key.get(key)
|
|
245
|
+
if payload is None:
|
|
246
|
+
continue
|
|
247
|
+
concrete = key[2]
|
|
248
|
+
|
|
249
|
+
for entry in payload.get("vulns") or []:
|
|
250
|
+
vuln = _entry_to_vulnerability(entry, dep, concrete, osv_name=key[1])
|
|
251
|
+
if vuln is None:
|
|
252
|
+
continue
|
|
253
|
+
fingerprint = (vuln.ecosystem, vuln.package_name, vuln.package_version, vuln.id)
|
|
254
|
+
if fingerprint in seen_keys:
|
|
255
|
+
continue
|
|
256
|
+
seen_keys.add(fingerprint)
|
|
257
|
+
vuln.source_analyzer = "cve"
|
|
258
|
+
# Carry lockfile provenance (#143) so a transitive CVE can report
|
|
259
|
+
# how it was pulled in.
|
|
260
|
+
vuln.direct = dep.direct
|
|
261
|
+
vuln.resolution_path = dep.via or ""
|
|
262
|
+
vulns.append(vuln)
|
|
263
|
+
|
|
264
|
+
vulns.sort(
|
|
265
|
+
key=lambda v: (
|
|
266
|
+
_SEVERITY_RANK.get(v.severity, 3),
|
|
267
|
+
v.ecosystem,
|
|
268
|
+
v.package_name,
|
|
269
|
+
v.id,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return vulns, summary
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- Package naming --------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_URL_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]{0,20}://")
|
|
279
|
+
_SCP_LIKE_RE = re.compile(r"^[^@/\s]{1,100}@([^:/\s]{1,255}):")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def normalize_swift_url(url: str) -> str | None:
|
|
283
|
+
"""OSV ``SwiftURL`` package name for a SwiftPM source URL (#255).
|
|
284
|
+
|
|
285
|
+
OSV records SwiftPM packages as ``host/owner/repo`` (``github.com/apple/
|
|
286
|
+
swift-nio``): no scheme, no credentials, no ``.git``, no trailing slash.
|
|
287
|
+
Accepts ``https://…``, ``ssh://git@…`` and scp-style ``git@host:owner/repo``.
|
|
288
|
+
Returns ``None`` for something that isn't a remote URL (a local path).
|
|
289
|
+
"""
|
|
290
|
+
text = (url or "").strip()
|
|
291
|
+
if not text:
|
|
292
|
+
return None
|
|
293
|
+
scp = _SCP_LIKE_RE.match(text)
|
|
294
|
+
if scp:
|
|
295
|
+
text = f"{scp.group(1)}/{text[scp.end():]}"
|
|
296
|
+
else:
|
|
297
|
+
scheme = _URL_SCHEME_RE.match(text)
|
|
298
|
+
if not scheme or scheme.group(0).lower() == "file://":
|
|
299
|
+
return None
|
|
300
|
+
text = text[scheme.end():]
|
|
301
|
+
text = text.rsplit("@", 1)[-1] if "@" in text.split("/", 1)[0] else text
|
|
302
|
+
host, _, path = text.partition("/")
|
|
303
|
+
path = path.strip("/")
|
|
304
|
+
if path.endswith(".git"):
|
|
305
|
+
path = path[: -len(".git")]
|
|
306
|
+
if not host or not path:
|
|
307
|
+
return None
|
|
308
|
+
return f"{host.lower()}/{path}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_SWIFT_URL_ECOSYSTEMS = frozenset({"swiftpm", "carthage"})
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def osv_package_name(dep: DependencyHint) -> str | None:
|
|
315
|
+
"""The package name OSV indexes ``dep`` under. For SwiftPM and Carthage
|
|
316
|
+
that is the source repository URL, which the Swift analyzer records in
|
|
317
|
+
``evidence_text`` (falling back to ``name`` if it already is a URL)."""
|
|
318
|
+
if dep.ecosystem not in _SWIFT_URL_ECOSYSTEMS:
|
|
319
|
+
return dep.name
|
|
320
|
+
for candidate in (dep.evidence_text or "", dep.name):
|
|
321
|
+
normalized = normalize_swift_url(candidate)
|
|
322
|
+
if normalized:
|
|
323
|
+
return normalized
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# --- Version resolution ----------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
_SEMVER_LOWER_BOUND_RE = re.compile(
|
|
331
|
+
r"(?P<major>\d+)(?:\.(?P<minor>\d+))?(?:\.(?P<patch>\d+))?"
|
|
332
|
+
r"(?P<pre>[-+][0-9A-Za-z.\-+]+)?"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def resolve_concrete_version(raw: str) -> str | None:
|
|
337
|
+
"""Best-effort: extract a queryable version from a manifest spec.
|
|
338
|
+
|
|
339
|
+
OSV needs a concrete version to answer "is this affected?". We
|
|
340
|
+
convert:
|
|
341
|
+
|
|
342
|
+
``^4.16.0`` → ``4.16.0``
|
|
343
|
+
``~5.4.0`` → ``5.4.0``
|
|
344
|
+
``>=2.28,<3`` → ``2.28``
|
|
345
|
+
``==21.2.0`` → ``21.2.0``
|
|
346
|
+
``v1.9.1`` → ``1.9.1``
|
|
347
|
+
``21.2.0`` → ``21.2.0``
|
|
348
|
+
``*`` / ``latest`` → None (unknown; skip)
|
|
349
|
+
`` `` → None
|
|
350
|
+
"""
|
|
351
|
+
if not raw:
|
|
352
|
+
return None
|
|
353
|
+
text = raw.strip()
|
|
354
|
+
if not text:
|
|
355
|
+
return None
|
|
356
|
+
lowered = text.lower()
|
|
357
|
+
if lowered in {"*", "any", "latest", "n/a"}:
|
|
358
|
+
return None
|
|
359
|
+
# Strip leading spec chars and comparator prefixes.
|
|
360
|
+
stripped = text.lstrip("^~=<>! ")
|
|
361
|
+
if stripped.startswith("v"):
|
|
362
|
+
stripped = stripped[1:]
|
|
363
|
+
# Multi-clause spec: ">=2.28,<3" — take the first bounded clause.
|
|
364
|
+
if "," in stripped:
|
|
365
|
+
first = stripped.split(",", 1)[0].strip().lstrip("^~=<>! ")
|
|
366
|
+
stripped = first
|
|
367
|
+
match = _SEMVER_LOWER_BOUND_RE.match(stripped)
|
|
368
|
+
if not match:
|
|
369
|
+
return None
|
|
370
|
+
major = match.group("major")
|
|
371
|
+
minor = match.group("minor") or "0"
|
|
372
|
+
patch = match.group("patch") or "0"
|
|
373
|
+
pre = match.group("pre") or ""
|
|
374
|
+
# Go modules canonicalize as vX.Y.Z; the OSV query accepts either
|
|
375
|
+
# form, but we normalize to bare numerics + optional prerelease.
|
|
376
|
+
return f"{major}.{minor}.{patch}{pre}"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --- Severity mapping ------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
|
|
383
|
+
|
|
384
|
+
_CVSS_SCORE_RE = re.compile(r"CVSS:[\d.]+/.*")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def map_severity(entry: dict) -> tuple[str, float | None]:
|
|
388
|
+
"""Map an OSV severity block to AttackMap's low/medium/high bucket.
|
|
389
|
+
|
|
390
|
+
OSV returns:
|
|
391
|
+
|
|
392
|
+
"severity": [
|
|
393
|
+
{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/..."},
|
|
394
|
+
...
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
plus sometimes a database-specific severity string ("MODERATE",
|
|
398
|
+
"HIGH"). We prefer the numeric CVSS score when it's present;
|
|
399
|
+
otherwise we map the label; otherwise default to medium.
|
|
400
|
+
"""
|
|
401
|
+
severities = entry.get("severity") or []
|
|
402
|
+
numeric_score: float | None = None
|
|
403
|
+
for s in severities:
|
|
404
|
+
raw = s.get("score") or ""
|
|
405
|
+
parsed = _cvss_base_score(raw)
|
|
406
|
+
if parsed is not None:
|
|
407
|
+
numeric_score = parsed
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
if numeric_score is not None:
|
|
411
|
+
if numeric_score >= 7.0:
|
|
412
|
+
return "high", numeric_score
|
|
413
|
+
if numeric_score >= 4.0:
|
|
414
|
+
return "medium", numeric_score
|
|
415
|
+
return "low", numeric_score
|
|
416
|
+
|
|
417
|
+
label = (entry.get("database_specific") or {}).get("severity") or ""
|
|
418
|
+
if isinstance(label, str):
|
|
419
|
+
upper = label.upper()
|
|
420
|
+
if upper in {"CRITICAL", "HIGH"}:
|
|
421
|
+
return "high", None
|
|
422
|
+
if upper in {"MODERATE", "MEDIUM"}:
|
|
423
|
+
return "medium", None
|
|
424
|
+
if upper == "LOW":
|
|
425
|
+
return "low", None
|
|
426
|
+
return "medium", None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _cvss_base_score(vector: str) -> float | None:
|
|
430
|
+
"""Extract the base score from an OSV score string.
|
|
431
|
+
|
|
432
|
+
OSV entries store the score as either a raw CVSS vector like
|
|
433
|
+
``CVSS:3.1/AV:N/AC:L/...`` or an already-computed float. We handle
|
|
434
|
+
both; for vectors we do a small, deliberately-incomplete
|
|
435
|
+
approximation from the exploitability/impact metrics — good enough
|
|
436
|
+
to bucket into low/medium/high without vendoring a full CVSS
|
|
437
|
+
calculator.
|
|
438
|
+
"""
|
|
439
|
+
if not vector:
|
|
440
|
+
return None
|
|
441
|
+
stripped = vector.strip()
|
|
442
|
+
# Case 1: a numeric-only score.
|
|
443
|
+
try:
|
|
444
|
+
return float(stripped)
|
|
445
|
+
except ValueError:
|
|
446
|
+
pass
|
|
447
|
+
# Case 2: a CVSS vector. Look for a trailing `... /S:...:...` — some
|
|
448
|
+
# advisories put a computed score in database_specific. But the raw
|
|
449
|
+
# vector doesn't include the number. Use a rough qualitative bump:
|
|
450
|
+
# if AV:N and PR:N and UI:N with a HIGH impact metric, treat as
|
|
451
|
+
# HIGH (~7.5+); else MEDIUM. This isn't full CVSS math — we're just
|
|
452
|
+
# bucketing.
|
|
453
|
+
#
|
|
454
|
+
# Since we're not implementing full CVSS scoring, return None and
|
|
455
|
+
# let the label fallback handle it. Most OSV entries also include
|
|
456
|
+
# a `database_specific.severity` label.
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# --- Vulnerability construction --------------------------------------------
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _entry_to_vulnerability(
|
|
464
|
+
entry: dict, dep: DependencyHint, concrete_version: str, *, osv_name: str | None = None
|
|
465
|
+
) -> Vulnerability | None:
|
|
466
|
+
vuln_id = entry.get("id")
|
|
467
|
+
if not isinstance(vuln_id, str) or not vuln_id:
|
|
468
|
+
return None
|
|
469
|
+
aliases = [str(a) for a in (entry.get("aliases") or []) if isinstance(a, str)]
|
|
470
|
+
summary = str(entry.get("summary") or "").strip()
|
|
471
|
+
severity, cvss = map_severity(entry)
|
|
472
|
+
references = [
|
|
473
|
+
str(r.get("url"))
|
|
474
|
+
for r in (entry.get("references") or [])
|
|
475
|
+
if isinstance(r, dict) and isinstance(r.get("url"), str)
|
|
476
|
+
]
|
|
477
|
+
affected_range = _first_affected_range(entry, osv_name or dep.name)
|
|
478
|
+
return Vulnerability(
|
|
479
|
+
id=vuln_id,
|
|
480
|
+
aliases=aliases,
|
|
481
|
+
summary=summary,
|
|
482
|
+
severity=severity, # type: ignore[arg-type]
|
|
483
|
+
cvss_score=cvss,
|
|
484
|
+
references=references,
|
|
485
|
+
affected_range=affected_range,
|
|
486
|
+
package_name=dep.name,
|
|
487
|
+
package_version=concrete_version,
|
|
488
|
+
ecosystem=dep.ecosystem, # type: ignore[arg-type]
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _first_affected_range(entry: dict, name: str) -> str:
|
|
493
|
+
for affected in entry.get("affected") or []:
|
|
494
|
+
pkg = affected.get("package") or {}
|
|
495
|
+
if str(pkg.get("name") or "").lower() != name.lower():
|
|
496
|
+
continue
|
|
497
|
+
ranges = affected.get("ranges") or []
|
|
498
|
+
for r in ranges:
|
|
499
|
+
events = r.get("events") or []
|
|
500
|
+
lo = next((e.get("introduced") for e in events if "introduced" in e), None)
|
|
501
|
+
hi = next((e.get("fixed") for e in events if "fixed" in e), None)
|
|
502
|
+
if lo or hi:
|
|
503
|
+
lo_s = lo if lo and lo != "0" else "0"
|
|
504
|
+
hi_s = f", fixed in {hi}" if hi else ""
|
|
505
|
+
return f"affected: >= {lo_s}{hi_s}"
|
|
506
|
+
return ""
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# --- Cache & transport -----------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _resolve_cache_dir(explicit: Path | None) -> Path:
|
|
513
|
+
if explicit is not None:
|
|
514
|
+
return explicit
|
|
515
|
+
override = os.environ.get("ATTACKMAP_CACHE_DIR")
|
|
516
|
+
if override:
|
|
517
|
+
return Path(override) / "osv"
|
|
518
|
+
return Path.home() / ".attackmap" / "cache" / "osv"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _ttl_seconds() -> int:
|
|
522
|
+
override = os.environ.get("ATTACKMAP_OSV_CACHE_TTL_HOURS")
|
|
523
|
+
if override:
|
|
524
|
+
try:
|
|
525
|
+
hours = int(override)
|
|
526
|
+
except ValueError:
|
|
527
|
+
hours = _DEFAULT_TTL_HOURS
|
|
528
|
+
else:
|
|
529
|
+
hours = _DEFAULT_TTL_HOURS
|
|
530
|
+
return max(0, hours) * 3600
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _cache_key(ecosystem: str, name: str, version: str) -> str:
|
|
534
|
+
return hashlib.sha256(f"{ecosystem}|{name}|{version}".encode("utf-8")).hexdigest()[:32]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _read_cache(path: Path, now: float, ttl_seconds: int) -> dict | None:
|
|
538
|
+
if not path.exists():
|
|
539
|
+
return None
|
|
540
|
+
try:
|
|
541
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
542
|
+
except (OSError, json.JSONDecodeError):
|
|
543
|
+
return None
|
|
544
|
+
fetched_at = data.get("fetched_at")
|
|
545
|
+
payload = data.get("response")
|
|
546
|
+
if not isinstance(fetched_at, (int, float)) or not isinstance(payload, dict):
|
|
547
|
+
return None
|
|
548
|
+
if ttl_seconds > 0 and now - fetched_at > ttl_seconds:
|
|
549
|
+
return None
|
|
550
|
+
return payload
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _write_cache(path: Path, payload: dict, now: float) -> None:
|
|
554
|
+
try:
|
|
555
|
+
path.write_text(
|
|
556
|
+
json.dumps({"fetched_at": now, "response": payload}, separators=(",", ":")),
|
|
557
|
+
encoding="utf-8",
|
|
558
|
+
)
|
|
559
|
+
except OSError:
|
|
560
|
+
# Cache is a nice-to-have; a write failure shouldn't sink the run.
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _fetch_osv_batch(
|
|
565
|
+
packages: list[tuple[str, str, str]],
|
|
566
|
+
transport: Callable[[str, bytes], bytes] | None,
|
|
567
|
+
summary: LookupSummary,
|
|
568
|
+
) -> list[dict] | None:
|
|
569
|
+
"""One ``POST /v1/querybatch`` for up to ``_BATCH_SIZE`` packages.
|
|
570
|
+
|
|
571
|
+
Returns the ``results`` list (aligned with ``packages`` by index — OSV
|
|
572
|
+
guarantees request order) or ``None`` on a network/shape failure, in
|
|
573
|
+
which case the whole chunk is treated as offline.
|
|
574
|
+
"""
|
|
575
|
+
body = json.dumps(
|
|
576
|
+
{
|
|
577
|
+
"queries": [
|
|
578
|
+
{"package": {"name": name, "ecosystem": osv_eco}, "version": version}
|
|
579
|
+
for osv_eco, name, version in packages
|
|
580
|
+
]
|
|
581
|
+
}
|
|
582
|
+
).encode("utf-8")
|
|
583
|
+
raw = _http(_OSV_BATCH_URL, body, transport, summary)
|
|
584
|
+
if raw is None:
|
|
585
|
+
return None
|
|
586
|
+
try:
|
|
587
|
+
payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
588
|
+
except json.JSONDecodeError:
|
|
589
|
+
summary.network_errors += 1
|
|
590
|
+
return None
|
|
591
|
+
results = payload.get("results") if isinstance(payload, dict) else None
|
|
592
|
+
if not isinstance(results, list) or len(results) != len(packages):
|
|
593
|
+
summary.network_errors += 1
|
|
594
|
+
return None
|
|
595
|
+
return results
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _assemble_details(
|
|
599
|
+
result: dict,
|
|
600
|
+
details_memo: dict[str, dict],
|
|
601
|
+
transport: Callable[[str, bytes], bytes] | None,
|
|
602
|
+
summary: LookupSummary,
|
|
603
|
+
) -> dict | None:
|
|
604
|
+
"""Turn one batch result's ID references into full advisory records.
|
|
605
|
+
|
|
606
|
+
Returns ``None`` if any advertised detail could not be fetched — the
|
|
607
|
+
caller must then skip caching the package, because caching a partial
|
|
608
|
+
payload would suppress a known vulnerability for the whole TTL (P1).
|
|
609
|
+
"""
|
|
610
|
+
full_entries = []
|
|
611
|
+
for ref in result.get("vulns") or []:
|
|
612
|
+
vuln_id = str(ref.get("id", "")) if isinstance(ref, dict) else ""
|
|
613
|
+
if not vuln_id:
|
|
614
|
+
continue # shape anomaly, not a fetch failure — nothing to fetch
|
|
615
|
+
entry = details_memo.get(vuln_id)
|
|
616
|
+
if entry is None:
|
|
617
|
+
entry = _fetch_osv_vuln(vuln_id, transport, summary)
|
|
618
|
+
if entry is None:
|
|
619
|
+
return None
|
|
620
|
+
details_memo[vuln_id] = entry
|
|
621
|
+
full_entries.append(entry)
|
|
622
|
+
return {"vulns": full_entries} if full_entries else {}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _fetch_osv_paged(
|
|
626
|
+
osv_ecosystem: str,
|
|
627
|
+
name: str,
|
|
628
|
+
version: str,
|
|
629
|
+
transport: Callable[[str, bytes], bytes] | None,
|
|
630
|
+
summary: LookupSummary,
|
|
631
|
+
) -> dict | None:
|
|
632
|
+
"""Classic ``POST /v1/query``, following ``next_page_token`` to the end.
|
|
633
|
+
|
|
634
|
+
Returns the union of all pages' ``vulns``, or ``None`` if any page fails
|
|
635
|
+
— never a partial result, for the same don't-cache-partials reason as
|
|
636
|
+
:func:`_assemble_details` (P2: single-query responses paginate too).
|
|
637
|
+
"""
|
|
638
|
+
all_vulns: list[dict] = []
|
|
639
|
+
page_token: str | None = None
|
|
640
|
+
while True:
|
|
641
|
+
query: dict = {
|
|
642
|
+
"package": {"name": name, "ecosystem": osv_ecosystem},
|
|
643
|
+
"version": version,
|
|
644
|
+
}
|
|
645
|
+
if page_token:
|
|
646
|
+
query["page_token"] = page_token
|
|
647
|
+
raw = _http(_OSV_QUERY_URL, json.dumps(query).encode("utf-8"), transport, summary)
|
|
648
|
+
if raw is None:
|
|
649
|
+
return None
|
|
650
|
+
try:
|
|
651
|
+
payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
652
|
+
except json.JSONDecodeError:
|
|
653
|
+
summary.network_errors += 1
|
|
654
|
+
return None
|
|
655
|
+
if not isinstance(payload, dict):
|
|
656
|
+
summary.network_errors += 1
|
|
657
|
+
return None
|
|
658
|
+
all_vulns.extend(payload.get("vulns") or [])
|
|
659
|
+
page_token = payload.get("next_page_token")
|
|
660
|
+
if not page_token:
|
|
661
|
+
break
|
|
662
|
+
if transport is None:
|
|
663
|
+
time.sleep(_INTER_REQUEST_SLEEP_S)
|
|
664
|
+
return {"vulns": all_vulns} if all_vulns else {}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _fetch_osv_vuln(
|
|
668
|
+
vuln_id: str,
|
|
669
|
+
transport: Callable[[str, bytes], bytes] | None,
|
|
670
|
+
summary: LookupSummary,
|
|
671
|
+
) -> dict | None:
|
|
672
|
+
"""Fetch one full advisory record via ``GET /v1/vulns/{id}``."""
|
|
673
|
+
raw = _http(_OSV_VULNS_URL + vuln_id, b"", transport, summary)
|
|
674
|
+
if raw is None:
|
|
675
|
+
return None
|
|
676
|
+
try:
|
|
677
|
+
entry = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
678
|
+
except json.JSONDecodeError:
|
|
679
|
+
summary.network_errors += 1
|
|
680
|
+
return None
|
|
681
|
+
return entry if isinstance(entry, dict) else None
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _http(
|
|
685
|
+
url: str,
|
|
686
|
+
body: bytes,
|
|
687
|
+
transport: Callable[[str, bytes], bytes] | None,
|
|
688
|
+
summary: LookupSummary,
|
|
689
|
+
) -> bytes | None:
|
|
690
|
+
"""One HTTP exchange: POST when ``body`` is non-empty, GET otherwise.
|
|
691
|
+
|
|
692
|
+
The injectable ``transport`` sees ``(url, body)`` either way, so test
|
|
693
|
+
stubs dispatch on the URL.
|
|
694
|
+
"""
|
|
695
|
+
try:
|
|
696
|
+
if transport is not None:
|
|
697
|
+
return transport(url, body)
|
|
698
|
+
request = urllib.request.Request(
|
|
699
|
+
url,
|
|
700
|
+
data=body or None,
|
|
701
|
+
headers={"Content-Type": "application/json"} if body else {},
|
|
702
|
+
method="POST" if body else "GET",
|
|
703
|
+
)
|
|
704
|
+
with urllib.request.urlopen(request, timeout=_REQUEST_TIMEOUT_S) as response:
|
|
705
|
+
return response.read()
|
|
706
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
707
|
+
summary.network_errors += 1
|
|
708
|
+
logger.debug("OSV network error for %s: %s", url, exc)
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
__all__ = [
|
|
713
|
+
"LookupSummary",
|
|
714
|
+
"map_severity",
|
|
715
|
+
"query_vulnerabilities",
|
|
716
|
+
"resolve_concrete_version",
|
|
717
|
+
]
|