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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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: "&gt;=2.28,&lt;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
+ ]