attackmap 0.4.26__tar.gz → 0.4.27__tar.gz

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.

Potentially problematic release.


This version of attackmap might be problematic. Click here for more details.

Files changed (119) hide show
  1. {attackmap-0.4.26/src/attackmap.egg-info → attackmap-0.4.27}/PKG-INFO +1 -1
  2. {attackmap-0.4.26 → attackmap-0.4.27}/pyproject.toml +1 -1
  3. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/cli.py +9 -3
  4. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/cve.py +226 -33
  5. {attackmap-0.4.26 → attackmap-0.4.27/src/attackmap.egg-info}/PKG-INFO +1 -1
  6. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_cve.py +188 -17
  7. {attackmap-0.4.26 → attackmap-0.4.27}/LICENSE +0 -0
  8. {attackmap-0.4.26 → attackmap-0.4.27}/README.md +0 -0
  9. {attackmap-0.4.26 → attackmap-0.4.27}/setup.cfg +0 -0
  10. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/__init__.py +0 -0
  11. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/analyzer.py +0 -0
  12. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/analyzer_contracts.py +0 -0
  13. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/analyzers.py +0 -0
  14. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/anomalies.py +0 -0
  15. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/asset_model.py +0 -0
  16. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/attack_taxonomy.py +0 -0
  17. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/authz.py +0 -0
  18. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/config_scanner.py +0 -0
  19. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/context_pack.py +0 -0
  20. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/contracts.py +0 -0
  21. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/control_model.py +0 -0
  22. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/crossrepo.py +0 -0
  23. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/crypto.py +0 -0
  24. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/defensive_review.py +0 -0
  25. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/detection_opportunities.py +0 -0
  26. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/diagrams.py +0 -0
  27. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/diff.py +0 -0
  28. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/exploitability.py +0 -0
  29. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/fleet.py +0 -0
  30. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/graph.py +0 -0
  31. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/hunt_harness.py +0 -0
  32. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/insights.py +0 -0
  33. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/llm_review.py +0 -0
  34. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/lockfiles.py +0 -0
  35. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/merge.py +0 -0
  36. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/models.py +0 -0
  37. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/progress.py +0 -0
  38. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/recon_models.py +0 -0
  39. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/recon_to_analysis.py +0 -0
  40. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/report.py +0 -0
  41. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/review_eval.py +0 -0
  42. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/review_json.py +0 -0
  43. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/review_prompts.py +0 -0
  44. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/route_auth_fusion.py +0 -0
  45. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sarif.py +0 -0
  46. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sbom.py +0 -0
  47. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/scanner.py +0 -0
  48. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sdk/__init__.py +0 -0
  49. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sdk/contracts.py +0 -0
  50. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sdk/models.py +0 -0
  51. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/security_overlay.py +0 -0
  52. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/srcpaths.py +0 -0
  53. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/suggest.py +0 -0
  54. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/suppress.py +0 -0
  55. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/taint.py +0 -0
  56. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/threat_model.py +0 -0
  57. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/topology.py +0 -0
  58. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/triage.py +0 -0
  59. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/weaknesses.py +0 -0
  60. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/webhardening.py +0 -0
  61. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/workflow_scanner.py +0 -0
  62. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/SOURCES.txt +0 -0
  63. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/dependency_links.txt +0 -0
  64. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/entry_points.txt +0 -0
  65. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/requires.txt +0 -0
  66. {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/top_level.txt +0 -0
  67. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_analyzer.py +0 -0
  68. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_analyzer_metadata_schema.py +0 -0
  69. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_analyzers.py +0 -0
  70. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_anomalies.py +0 -0
  71. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_artifact_schemas.py +0 -0
  72. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_authz.py +0 -0
  73. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_config_scanner.py +0 -0
  74. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_context_pack.py +0 -0
  75. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_contracts.py +0 -0
  76. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_crossrepo.py +0 -0
  77. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_crypto.py +0 -0
  78. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_defensive_review.py +0 -0
  79. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_diagrams.py +0 -0
  80. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_diff.py +0 -0
  81. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_exploitability.py +0 -0
  82. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_fleet.py +0 -0
  83. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_graph.py +0 -0
  84. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_hunt.py +0 -0
  85. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_hunt_harness.py +0 -0
  86. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_injection_sinks.py +0 -0
  87. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_llm_review.py +0 -0
  88. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_lockfiles.py +0 -0
  89. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_merge.py +0 -0
  90. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_modules_cli.py +0 -0
  91. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_pr_comment.py +0 -0
  92. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_progress.py +0 -0
  93. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_provenance.py +0 -0
  94. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_recall.py +0 -0
  95. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_recon_to_analysis.py +0 -0
  96. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_remediate.py +0 -0
  97. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_report.py +0 -0
  98. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_review_eval.py +0 -0
  99. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_review_json.py +0 -0
  100. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_review_prompts.py +0 -0
  101. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_route_auth_fusion.py +0 -0
  102. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_sarif.py +0 -0
  103. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_sbom.py +0 -0
  104. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_scanner.py +0 -0
  105. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_security_overlay.py +0 -0
  106. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_shared_contract_imports.py +0 -0
  107. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_signal_v2.py +0 -0
  108. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_srcpaths.py +0 -0
  109. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_suggest.py +0 -0
  110. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_suppress.py +0 -0
  111. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_taint.py +0 -0
  112. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_taint_sanitizer.py +0 -0
  113. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_threat_intel.py +0 -0
  114. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_threat_model.py +0 -0
  115. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_topology.py +0 -0
  116. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_triage.py +0 -0
  117. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_weaknesses.py +0 -0
  118. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_webhardening.py +0 -0
  119. {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_workflow_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.26
3
+ Version: 0.4.27
4
4
  Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
5
5
  Author: AttackMap Contributors
6
6
  Author-email: Matthew Davis <matthewd@matthewd.xyz>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "attackmap"
7
- version = "0.4.26"
7
+ version = "0.4.27"
8
8
  description = "AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -126,7 +126,9 @@ def _run_fleet(
126
126
  )
127
127
  if cve and scan.dependencies:
128
128
  try:
129
- vulns, _cve_summary = query_vulnerabilities(scan.dependencies)
129
+ vulns, _cve_summary = query_vulnerabilities(
130
+ scan.dependencies, progress=scan_progress
131
+ )
130
132
  finally:
131
133
  scan_progress.done()
132
134
  scan.vulnerabilities = vulns
@@ -434,9 +436,13 @@ def analyze(
434
436
  )
435
437
  if cve and scan.dependencies:
436
438
  typer.echo(f"Checking {len(scan.dependencies)} dependencies against OSV.dev…")
437
- scan_progress.stage(f"Checking {len(scan.dependencies)} dependencies against OSV.dev")
439
+ # query_vulnerabilities drives a determinate begin/advance bar itself
440
+ # (per unique package), so big lockfile trees show real progress + ETA
441
+ # instead of an indeterminate spinner.
438
442
  try:
439
- vulns, cve_summary = query_vulnerabilities(scan.dependencies)
443
+ vulns, cve_summary = query_vulnerabilities(
444
+ scan.dependencies, progress=scan_progress
445
+ )
440
446
  finally:
441
447
  scan_progress.done()
442
448
  scan.vulnerabilities = vulns
@@ -7,9 +7,16 @@ and doesn't belong in an offline-only scan.
7
7
 
8
8
  ## Design summary
9
9
 
10
- - **Endpoint**: ``POST https://api.osv.dev/v1/query`` per (ecosystem,
11
- name, version). Full details in a single response; simpler cache
12
- semantics than the batch + fetch-by-id combo.
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.
13
20
  - **Concrete version resolution**: OSV needs a concrete version to
14
21
  match against affected ranges. Manifests carry range specs
15
22
  (``^4.16.0``, ``>=2.28,<3``, ``latest``); we extract a best-effort
@@ -48,6 +55,10 @@ logger = logging.getLogger(__name__)
48
55
 
49
56
 
50
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
51
62
  _OSV_ECOSYSTEM: dict[str, str] = {
52
63
  "pypi": "PyPI",
53
64
  "npm": "npm",
@@ -85,6 +96,7 @@ def query_vulnerabilities(
85
96
  cache_dir: Path | None = None,
86
97
  transport: Callable[[str, bytes], bytes] | None = None,
87
98
  clock: Callable[[], float] | None = None,
99
+ progress=None,
88
100
  ) -> tuple[list[Vulnerability], LookupSummary]:
89
101
  """Look up CVEs for each ``DependencyHint`` and return records.
90
102
 
@@ -98,9 +110,15 @@ def query_vulnerabilities(
98
110
  supply a per-test tmpdir here.
99
111
  transport
100
112
  Injectable network transport ``(url, body) -> response_bytes``.
101
- Tests pass a stub; default is urllib.
113
+ Tests pass a stub; default is urllib. Detail lookups pass an
114
+ empty body (a GET in the default transport).
102
115
  clock
103
116
  Injectable ``time.time`` for deterministic cache-TTL tests.
117
+ progress
118
+ Optional duck-typed progress sink with ``begin(total, label)``
119
+ and ``advance(current)`` — the CLI passes its ``ScanProgress``
120
+ so a long lookup renders as a determinate bar, not a stalled
121
+ spinner.
104
122
  """
105
123
  cache = _resolve_cache_dir(cache_dir)
106
124
  cache.mkdir(parents=True, exist_ok=True)
@@ -111,6 +129,12 @@ def query_vulnerabilities(
111
129
  summary = LookupSummary()
112
130
  seen_keys: set[tuple[str, str, str, str]] = set()
113
131
 
132
+ # Pass 1 — resolve each hint to a concrete queryable version and dedupe
133
+ # to unique (ecosystem, name, version) packages. A lockfile monorepo can
134
+ # carry thousands of hints; querying per unique package (batched below)
135
+ # instead of per hint is what keeps --cve tractable.
136
+ entries: list[tuple[DependencyHint, tuple[str, str, str]]] = []
137
+ unique: dict[tuple[str, str, str], tuple[str, str, str]] = {} # key -> (osv_eco, name, concrete)
114
138
  for dep in dependencies:
115
139
  osv_eco = _OSV_ECOSYSTEM.get(dep.ecosystem)
116
140
  if osv_eco is None:
@@ -125,21 +149,76 @@ def query_vulnerabilities(
125
149
  if concrete is None:
126
150
  summary.skipped_no_version += 1
127
151
  continue
128
-
129
152
  key = (dep.ecosystem, dep.name, concrete)
130
- cache_path = cache / (_cache_key(*key) + ".json")
153
+ entries.append((dep, key))
154
+ unique.setdefault(key, (osv_eco, dep.name, concrete))
131
155
 
132
- payload: dict | None = _read_cache(cache_path, now, ttl_seconds)
156
+ if progress is not None:
157
+ progress.begin(len(unique), label="Checking dependencies against OSV.dev")
158
+
159
+ # Pass 2 — serve unique packages from the cache where fresh.
160
+ payload_by_key: dict[tuple[str, str, str], dict] = {}
161
+ for key in unique:
162
+ cache_path = cache / (_cache_key(*key) + ".json")
163
+ payload = _read_cache(cache_path, now, ttl_seconds)
133
164
  if payload is not None:
165
+ payload_by_key[key] = payload
134
166
  summary.cached += 1
135
- else:
136
- payload = _fetch_osv(osv_eco, dep.name, concrete, transport, summary)
167
+ if progress is not None:
168
+ progress.advance(key[1])
169
+
170
+ # Pass 3 — batch-query the misses (one POST per _BATCH_SIZE packages),
171
+ # then fetch full advisory records only for the IDs that actually hit,
172
+ # memoized so a CVE shared by many packages is fetched once.
173
+ misses = [key for key in unique if key not in payload_by_key]
174
+ details_memo: dict[str, dict] = {}
175
+ for start in range(0, len(misses), _BATCH_SIZE):
176
+ chunk = misses[start : start + _BATCH_SIZE]
177
+ results = _fetch_osv_batch([unique[key] for key in chunk], transport, summary)
178
+ if results is None:
179
+ # Whole batch unreachable — same offline semantics as before,
180
+ # tallied per package so the summary line stays meaningful.
181
+ summary.skipped_offline += len(chunk)
182
+ if progress is not None:
183
+ for key in chunk:
184
+ progress.advance(key[1])
185
+ continue
186
+ for key, result in zip(chunk, results):
187
+ osv_eco, name, concrete = unique[key]
188
+ payload: dict | None
189
+ if not isinstance(result, dict):
190
+ result = {}
191
+ if result.get("next_page_token"):
192
+ # A package with >1 page of advisories (rare): fall back to
193
+ # the classic single query, following ITS pagination too —
194
+ # exactly these high-advisory packages paginate there as well.
195
+ payload = _fetch_osv_paged(osv_eco, name, concrete, transport, summary)
196
+ else:
197
+ # Assemble the full advisory records for this package. If any
198
+ # detail fetch fails, the package must NOT be cached — a
199
+ # partial payload would suppress a known vulnerability for the
200
+ # whole cache TTL. Treat it as offline for this run instead.
201
+ payload = _assemble_details(result, details_memo, transport, summary)
137
202
  if payload is None:
138
- continue # skipped_offline / network_errors already tallied
139
- _write_cache(cache_path, payload, now)
203
+ summary.skipped_offline += 1
204
+ if progress is not None:
205
+ progress.advance(name)
206
+ continue
207
+ payload_by_key[key] = payload
208
+ _write_cache(cache / (_cache_key(*key) + ".json"), payload, now)
140
209
  summary.queried += 1
141
- if transport is None:
142
- time.sleep(_INTER_REQUEST_SLEEP_S)
210
+ if progress is not None:
211
+ progress.advance(name)
212
+ if transport is None and start + _BATCH_SIZE < len(misses):
213
+ time.sleep(_INTER_REQUEST_SLEEP_S)
214
+
215
+ # Pass 4 — fan the per-package payloads back out to every hint (dedup by
216
+ # (eco, name, version, id) keeps multi-lockfile repeats from double-emitting).
217
+ for dep, key in entries:
218
+ payload = payload_by_key.get(key)
219
+ if payload is None:
220
+ continue
221
+ concrete = key[2]
143
222
 
144
223
  for entry in payload.get("vulns") or []:
145
224
  vuln = _entry_to_vulnerability(entry, dep, concrete)
@@ -404,37 +483,151 @@ def _write_cache(path: Path, payload: dict, now: float) -> None:
404
483
  pass
405
484
 
406
485
 
407
- def _fetch_osv(
486
+ def _fetch_osv_batch(
487
+ packages: list[tuple[str, str, str]],
488
+ transport: Callable[[str, bytes], bytes] | None,
489
+ summary: LookupSummary,
490
+ ) -> list[dict] | None:
491
+ """One ``POST /v1/querybatch`` for up to ``_BATCH_SIZE`` packages.
492
+
493
+ Returns the ``results`` list (aligned with ``packages`` by index — OSV
494
+ guarantees request order) or ``None`` on a network/shape failure, in
495
+ which case the whole chunk is treated as offline.
496
+ """
497
+ body = json.dumps(
498
+ {
499
+ "queries": [
500
+ {"package": {"name": name, "ecosystem": osv_eco}, "version": version}
501
+ for osv_eco, name, version in packages
502
+ ]
503
+ }
504
+ ).encode("utf-8")
505
+ raw = _http(_OSV_BATCH_URL, body, transport, summary)
506
+ if raw is None:
507
+ return None
508
+ try:
509
+ payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
510
+ except json.JSONDecodeError:
511
+ summary.network_errors += 1
512
+ return None
513
+ results = payload.get("results") if isinstance(payload, dict) else None
514
+ if not isinstance(results, list) or len(results) != len(packages):
515
+ summary.network_errors += 1
516
+ return None
517
+ return results
518
+
519
+
520
+ def _assemble_details(
521
+ result: dict,
522
+ details_memo: dict[str, dict],
523
+ transport: Callable[[str, bytes], bytes] | None,
524
+ summary: LookupSummary,
525
+ ) -> dict | None:
526
+ """Turn one batch result's ID references into full advisory records.
527
+
528
+ Returns ``None`` if any advertised detail could not be fetched — the
529
+ caller must then skip caching the package, because caching a partial
530
+ payload would suppress a known vulnerability for the whole TTL (P1).
531
+ """
532
+ full_entries = []
533
+ for ref in result.get("vulns") or []:
534
+ vuln_id = str(ref.get("id", "")) if isinstance(ref, dict) else ""
535
+ if not vuln_id:
536
+ continue # shape anomaly, not a fetch failure — nothing to fetch
537
+ entry = details_memo.get(vuln_id)
538
+ if entry is None:
539
+ entry = _fetch_osv_vuln(vuln_id, transport, summary)
540
+ if entry is None:
541
+ return None
542
+ details_memo[vuln_id] = entry
543
+ full_entries.append(entry)
544
+ return {"vulns": full_entries} if full_entries else {}
545
+
546
+
547
+ def _fetch_osv_paged(
408
548
  osv_ecosystem: str,
409
549
  name: str,
410
550
  version: str,
411
551
  transport: Callable[[str, bytes], bytes] | None,
412
552
  summary: LookupSummary,
413
553
  ) -> dict | None:
414
- body = json.dumps(
415
- {"package": {"name": name, "ecosystem": osv_ecosystem}, "version": version}
416
- ).encode("utf-8")
554
+ """Classic ``POST /v1/query``, following ``next_page_token`` to the end.
555
+
556
+ Returns the union of all pages' ``vulns``, or ``None`` if any page fails
557
+ — never a partial result, for the same don't-cache-partials reason as
558
+ :func:`_assemble_details` (P2: single-query responses paginate too).
559
+ """
560
+ all_vulns: list[dict] = []
561
+ page_token: str | None = None
562
+ while True:
563
+ query: dict = {
564
+ "package": {"name": name, "ecosystem": osv_ecosystem},
565
+ "version": version,
566
+ }
567
+ if page_token:
568
+ query["page_token"] = page_token
569
+ raw = _http(_OSV_QUERY_URL, json.dumps(query).encode("utf-8"), transport, summary)
570
+ if raw is None:
571
+ return None
572
+ try:
573
+ payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
574
+ except json.JSONDecodeError:
575
+ summary.network_errors += 1
576
+ return None
577
+ if not isinstance(payload, dict):
578
+ summary.network_errors += 1
579
+ return None
580
+ all_vulns.extend(payload.get("vulns") or [])
581
+ page_token = payload.get("next_page_token")
582
+ if not page_token:
583
+ break
584
+ if transport is None:
585
+ time.sleep(_INTER_REQUEST_SLEEP_S)
586
+ return {"vulns": all_vulns} if all_vulns else {}
587
+
588
+
589
+ def _fetch_osv_vuln(
590
+ vuln_id: str,
591
+ transport: Callable[[str, bytes], bytes] | None,
592
+ summary: LookupSummary,
593
+ ) -> dict | None:
594
+ """Fetch one full advisory record via ``GET /v1/vulns/{id}``."""
595
+ raw = _http(_OSV_VULNS_URL + vuln_id, b"", transport, summary)
596
+ if raw is None:
597
+ return None
417
598
  try:
418
- if transport is not None:
419
- raw = transport(_OSV_QUERY_URL, body)
420
- else:
421
- request = urllib.request.Request(
422
- _OSV_QUERY_URL,
423
- data=body,
424
- headers={"Content-Type": "application/json"},
425
- method="POST",
426
- )
427
- with urllib.request.urlopen(request, timeout=_REQUEST_TIMEOUT_S) as response:
428
- raw = response.read()
429
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
599
+ entry = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
600
+ except json.JSONDecodeError:
430
601
  summary.network_errors += 1
431
- summary.skipped_offline += 1
432
- logger.debug("OSV network error for %s/%s@%s: %s", osv_ecosystem, name, version, exc)
433
602
  return None
603
+ return entry if isinstance(entry, dict) else None
604
+
605
+
606
+ def _http(
607
+ url: str,
608
+ body: bytes,
609
+ transport: Callable[[str, bytes], bytes] | None,
610
+ summary: LookupSummary,
611
+ ) -> bytes | None:
612
+ """One HTTP exchange: POST when ``body`` is non-empty, GET otherwise.
613
+
614
+ The injectable ``transport`` sees ``(url, body)`` either way, so test
615
+ stubs dispatch on the URL.
616
+ """
434
617
  try:
435
- return json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
436
- except json.JSONDecodeError:
618
+ if transport is not None:
619
+ return transport(url, body)
620
+ request = urllib.request.Request(
621
+ url,
622
+ data=body or None,
623
+ headers={"Content-Type": "application/json"} if body else {},
624
+ method="POST" if body else "GET",
625
+ )
626
+ with urllib.request.urlopen(request, timeout=_REQUEST_TIMEOUT_S) as response:
627
+ return response.read()
628
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
437
629
  summary.network_errors += 1
630
+ logger.debug("OSV network error for %s: %s", url, exc)
438
631
  return None
439
632
 
440
633
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.26
3
+ Version: 0.4.27
4
4
  Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
5
5
  Author: AttackMap Contributors
6
6
  Author-email: Matthew Davis <matthewd@matthewd.xyz>
@@ -129,21 +129,49 @@ def _osv_vuln(
129
129
 
130
130
 
131
131
  def _stub_transport(responses_by_body_hint: dict[str, dict]):
132
- """Return a transport that maps request bodies to canned responses.
132
+ """Return a transport speaking the OSV batch + detail protocol.
133
133
 
134
- ``responses_by_body_hint`` keys are substrings we expect in the JSON
135
- request body (usually the package name); values are the JSON dict
136
- to return.
134
+ ``responses_by_body_hint`` keys are substrings matched against the
135
+ queried package name; values are classic ``/v1/query``-shaped dicts
136
+ (``{"vulns": [full entries]}``). The stub answers:
137
+
138
+ - ``POST /v1/querybatch`` → per-query ID references derived from the
139
+ matched canned payload,
140
+ - ``GET /v1/vulns/{id}`` → the full canned entry for that ID,
141
+ - ``POST /v1/query`` (pagination fallback) → the canned payload as-is.
137
142
  """
138
143
  calls: list[tuple[str, bytes]] = []
139
144
 
145
+ def _match(text: str) -> dict | None:
146
+ for hint, payload in responses_by_body_hint.items():
147
+ if hint in text:
148
+ return payload
149
+ return None
150
+
140
151
  def transport(url: str, body: bytes) -> bytes:
141
152
  calls.append((url, body))
142
- body_text = body.decode("utf-8")
143
- for hint, payload in responses_by_body_hint.items():
144
- if hint in body_text:
145
- return json.dumps(payload).encode("utf-8")
146
- return json.dumps({"vulns": []}).encode("utf-8")
153
+ if url.endswith("/v1/querybatch"):
154
+ request = json.loads(body.decode("utf-8"))
155
+ results = []
156
+ for query in request["queries"]:
157
+ payload = _match(query["package"]["name"])
158
+ if payload and payload.get("vulns"):
159
+ results.append(
160
+ {"vulns": [{"id": v["id"], "modified": ""} for v in payload["vulns"]]}
161
+ )
162
+ else:
163
+ results.append({})
164
+ return json.dumps({"results": results}).encode("utf-8")
165
+ if "/v1/vulns/" in url:
166
+ vuln_id = url.rsplit("/", 1)[1]
167
+ for payload in responses_by_body_hint.values():
168
+ for entry in payload.get("vulns", []):
169
+ if entry["id"] == vuln_id:
170
+ return json.dumps(entry).encode("utf-8")
171
+ return json.dumps({}).encode("utf-8")
172
+ # Classic single query (the pagination fallback path).
173
+ payload = _match(body.decode("utf-8"))
174
+ return json.dumps(payload if payload else {"vulns": []}).encode("utf-8")
147
175
 
148
176
  return transport, calls
149
177
 
@@ -165,14 +193,14 @@ def test_query_returns_vulnerabilities_from_stubbed_response(tmp_path: Path) ->
165
193
 
166
194
 
167
195
  def test_query_dedups_repeated_vulns_across_deps(tmp_path: Path) -> None:
168
- """Two DependencyHints for the same (eco, name, version) share the
169
- same underlying OSV response — we only surface one Vulnerability."""
196
+ """Two DependencyHints for the same (eco, name, version) dedupe to one
197
+ unique package — queried once, surfaced once."""
170
198
  transport, calls = _stub_transport({"sample-pkg": {"vulns": [_osv_vuln()]}})
171
199
  deps = [_dep(), _dep()] # same fingerprint twice
172
200
  vulns, summary = query_vulnerabilities(deps, cache_dir=tmp_path, transport=transport)
173
201
  assert len(vulns) == 1
174
- # The second call hits the cache we just wrote.
175
- assert summary.cached == 1
202
+ assert summary.queried == 1
203
+ assert summary.cached == 0
176
204
 
177
205
 
178
206
  def test_query_skips_deps_without_queryable_version(tmp_path: Path) -> None:
@@ -192,8 +220,9 @@ def test_query_uses_cache_on_second_call(tmp_path: Path) -> None:
192
220
  transport, calls = _stub_transport({"sample-pkg": {"vulns": [_osv_vuln()]}})
193
221
  dep = _dep()
194
222
  vulns1, s1 = query_vulnerabilities([dep], cache_dir=tmp_path, transport=transport)
223
+ first_run_calls = len(calls) # one batch POST + one detail GET
195
224
  vulns2, s2 = query_vulnerabilities([dep], cache_dir=tmp_path, transport=transport)
196
- assert len(calls) == 1 # transport called once, second run hit cache
225
+ assert len(calls) == first_run_calls # second run hit the cache — no network
197
226
  assert s1.queried == 1 and s2.cached == 1
198
227
  # Vulns from the cached run should mirror the network run.
199
228
  assert [v.id for v in vulns2] == [v.id for v in vulns1]
@@ -204,12 +233,14 @@ def test_query_ignores_stale_cache_beyond_ttl(tmp_path: Path, monkeypatch: pytes
204
233
  dep = _dep()
205
234
  # First call at t=0.
206
235
  query_vulnerabilities([dep], cache_dir=tmp_path, transport=transport, clock=lambda: 0.0)
207
- assert len(calls) == 1
208
- # Second call 25 hours later with default 24h TTL — cache invalidated.
236
+ first_run_calls = len(calls)
237
+ assert first_run_calls > 0
238
+ # Second call 25 hours later with default 24h TTL — cache invalidated,
239
+ # so the network is hit again.
209
240
  monkeypatch.setenv("ATTACKMAP_OSV_CACHE_TTL_HOURS", "24")
210
241
  later = 25 * 3600.0
211
242
  query_vulnerabilities([dep], cache_dir=tmp_path, transport=transport, clock=lambda: later)
212
- assert len(calls) == 2
243
+ assert len(calls) > first_run_calls
213
244
 
214
245
 
215
246
  def test_query_offline_transport_error_falls_back_gracefully(tmp_path: Path) -> None:
@@ -259,6 +290,146 @@ def test_query_extracts_affected_range_text(tmp_path: Path) -> None:
259
290
  assert vulns[0].affected_range == "affected: >= 0, fixed in 1.2.3"
260
291
 
261
292
 
293
+ def test_query_batches_many_packages_into_few_requests(tmp_path: Path) -> None:
294
+ """The whole point of querybatch (#cve-batch): a big lockfile tree must
295
+ not produce one HTTP request per package. 700 unique clean packages →
296
+ 2 batch POSTs (500 + 200) and zero detail fetches."""
297
+ from attackmap.cve import _BATCH_SIZE, _OSV_BATCH_URL
298
+
299
+ transport, calls = _stub_transport({})
300
+ deps = [_dep(name=f"pkg-{i}") for i in range(_BATCH_SIZE + 200)]
301
+ vulns, summary = query_vulnerabilities(deps, cache_dir=tmp_path, transport=transport)
302
+ assert vulns == []
303
+ assert summary.queried == _BATCH_SIZE + 200
304
+ batch_calls = [c for c in calls if c[0] == _OSV_BATCH_URL]
305
+ assert len(batch_calls) == 2
306
+ assert len(calls) == 2 # no detail fetches for clean packages
307
+
308
+
309
+ def test_query_fetches_shared_vuln_details_once(tmp_path: Path) -> None:
310
+ """Two packages hit by the same advisory ID → one /v1/vulns/{id} fetch
311
+ (memoized per run), both packages still emit their Vulnerability."""
312
+ from attackmap.cve import _OSV_VULNS_URL
313
+
314
+ shared = _osv_vuln("GHSA-shared-1")
315
+ transport, calls = _stub_transport({"pkg-a": {"vulns": [shared]}, "pkg-b": {"vulns": [shared]}})
316
+ deps = [_dep(name="pkg-a"), _dep(name="pkg-b")]
317
+ vulns, summary = query_vulnerabilities(deps, cache_dir=tmp_path, transport=transport)
318
+ assert summary.queried == 2
319
+ assert {v.package_name for v in vulns} == {"pkg-a", "pkg-b"}
320
+ detail_calls = [c for c in calls if c[0].startswith(_OSV_VULNS_URL)]
321
+ assert len(detail_calls) == 1
322
+
323
+
324
+ def test_query_failed_detail_fetch_is_not_cached(tmp_path: Path) -> None:
325
+ """Codex P1: if the batch names an advisory but its /v1/vulns/{id} fetch
326
+ fails, the package must NOT be cached — a cached partial/empty payload
327
+ would suppress a known vulnerability for the whole TTL. The next run
328
+ (connectivity restored) must query again and surface the vuln."""
329
+ from attackmap.cve import _OSV_VULNS_URL
330
+
331
+ good_transport, _ = _stub_transport({"sample-pkg": {"vulns": [_osv_vuln()]}})
332
+
333
+ def flaky_transport(url: str, body: bytes) -> bytes:
334
+ if url.startswith(_OSV_VULNS_URL):
335
+ raise OSError("transient network error")
336
+ return good_transport(url, body)
337
+
338
+ vulns1, s1 = query_vulnerabilities([_dep()], cache_dir=tmp_path, transport=flaky_transport)
339
+ assert vulns1 == []
340
+ assert s1.queried == 0
341
+ assert s1.skipped_offline == 1
342
+
343
+ # Connectivity restored: no stale cache entry may mask the advisory.
344
+ vulns2, s2 = query_vulnerabilities([_dep()], cache_dir=tmp_path, transport=good_transport)
345
+ assert [v.id for v in vulns2] == ["GHSA-test-1234"]
346
+ assert s2.queried == 1
347
+ assert s2.cached == 0
348
+
349
+
350
+ def test_query_fallback_follows_single_query_pagination(tmp_path: Path) -> None:
351
+ """Codex P2: the single-query fallback must follow /v1/query's OWN
352
+ next_page_token — the high-advisory packages that enter this branch
353
+ paginate there too. All pages' vulns are merged; nothing is dropped."""
354
+ from attackmap.cve import _OSV_BATCH_URL, _OSV_QUERY_URL
355
+
356
+ page1 = {"vulns": [_osv_vuln("GHSA-page1-1")], "next_page_token": "page2"}
357
+ page2 = {"vulns": [_osv_vuln("GHSA-page2-1")]}
358
+
359
+ def transport(url: str, body: bytes) -> bytes:
360
+ if url == _OSV_BATCH_URL:
361
+ return json.dumps(
362
+ {"results": [{"vulns": [{"id": "GHSA-page1-1", "modified": ""}], "next_page_token": "tok"}]}
363
+ ).encode("utf-8")
364
+ if url == _OSV_QUERY_URL:
365
+ request = json.loads(body.decode("utf-8"))
366
+ return json.dumps(page2 if request.get("page_token") == "page2" else page1).encode("utf-8")
367
+ raise AssertionError(f"unexpected URL {url}")
368
+
369
+ vulns, summary = query_vulnerabilities([_dep()], cache_dir=tmp_path, transport=transport)
370
+ assert sorted(v.id for v in vulns) == ["GHSA-page1-1", "GHSA-page2-1"]
371
+ assert summary.queried == 1
372
+
373
+ # And the complete (both-pages) payload is what got cached.
374
+ vulns_cached, s2 = query_vulnerabilities(
375
+ [_dep()], cache_dir=tmp_path, transport=transport
376
+ )
377
+ assert sorted(v.id for v in vulns_cached) == ["GHSA-page1-1", "GHSA-page2-1"]
378
+ assert s2.cached == 1
379
+
380
+
381
+ def test_query_paginated_batch_result_falls_back_to_single_query(tmp_path: Path) -> None:
382
+ """A batch result carrying next_page_token (rare: >1 page of advisories
383
+ for one package) falls back to the classic full /v1/query for it."""
384
+ from attackmap.cve import _OSV_BATCH_URL, _OSV_QUERY_URL
385
+
386
+ calls: list[tuple[str, bytes]] = []
387
+ full_payload = {"vulns": [_osv_vuln("GHSA-paged-1")]}
388
+
389
+ def transport(url: str, body: bytes) -> bytes:
390
+ calls.append((url, body))
391
+ if url == _OSV_BATCH_URL:
392
+ return json.dumps(
393
+ {"results": [{"vulns": [{"id": "GHSA-paged-1", "modified": ""}], "next_page_token": "tok"}]}
394
+ ).encode("utf-8")
395
+ if url == _OSV_QUERY_URL:
396
+ return json.dumps(full_payload).encode("utf-8")
397
+ raise AssertionError(f"unexpected URL {url}")
398
+
399
+ vulns, summary = query_vulnerabilities([_dep()], cache_dir=tmp_path, transport=transport)
400
+ assert [v.id for v in vulns] == ["GHSA-paged-1"]
401
+ assert any(c[0] == _OSV_QUERY_URL for c in calls)
402
+
403
+
404
+ def test_query_reports_determinate_progress(tmp_path: Path) -> None:
405
+ """The progress sink sees begin(total=unique packages) and one advance
406
+ per package — cache hits and network results alike."""
407
+
408
+ class _FakeProgress:
409
+ def __init__(self) -> None:
410
+ self.begun: tuple[int, str] | None = None
411
+ self.advanced: list[str] = []
412
+
413
+ def begin(self, total: int, label: str = "") -> None:
414
+ self.begun = (total, label)
415
+
416
+ def advance(self, current: str = "") -> None:
417
+ self.advanced.append(current)
418
+
419
+ transport, _ = _stub_transport({"pkg-a": {"vulns": [_osv_vuln()]}})
420
+ deps = [_dep(name="pkg-a"), _dep(name="pkg-b"), _dep(name="pkg-b")] # 2 unique
421
+ sink = _FakeProgress()
422
+ query_vulnerabilities(deps, cache_dir=tmp_path, transport=transport, progress=sink)
423
+ assert sink.begun is not None and sink.begun[0] == 2
424
+ assert len(sink.advanced) == 2
425
+
426
+ # A warm-cache rerun advances through every package instantly.
427
+ sink2 = _FakeProgress()
428
+ query_vulnerabilities(deps, cache_dir=tmp_path, transport=transport, progress=sink2)
429
+ assert sink2.begun is not None and sink2.begun[0] == 2
430
+ assert len(sink2.advanced) == 2
431
+
432
+
262
433
  # ---------------------------------------------------------------------------
263
434
  # Finding emission via threat_model.generate_findings
264
435
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes