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.
- {attackmap-0.4.26/src/attackmap.egg-info → attackmap-0.4.27}/PKG-INFO +1 -1
- {attackmap-0.4.26 → attackmap-0.4.27}/pyproject.toml +1 -1
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/cli.py +9 -3
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/cve.py +226 -33
- {attackmap-0.4.26 → attackmap-0.4.27/src/attackmap.egg-info}/PKG-INFO +1 -1
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_cve.py +188 -17
- {attackmap-0.4.26 → attackmap-0.4.27}/LICENSE +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/README.md +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/setup.cfg +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/__init__.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/analyzers.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/anomalies.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/authz.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/config_scanner.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/contracts.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/control_model.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/crossrepo.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/crypto.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/diagrams.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/diff.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/exploitability.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/fleet.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/graph.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/hunt_harness.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/insights.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/llm_review.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/lockfiles.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/merge.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/models.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/progress.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/report.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/review_json.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/route_auth_fusion.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sarif.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sbom.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/scanner.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sdk/__init__.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/srcpaths.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/suggest.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/suppress.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/taint.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/threat_model.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/topology.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/triage.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/weaknesses.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/webhardening.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap/workflow_scanner.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/SOURCES.txt +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/requires.txt +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_analyzer.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_analyzer_metadata_schema.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_analyzers.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_anomalies.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_authz.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_config_scanner.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_context_pack.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_contracts.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_crossrepo.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_crypto.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_defensive_review.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_diagrams.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_diff.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_exploitability.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_fleet.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_graph.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_hunt.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_hunt_harness.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_injection_sinks.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_llm_review.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_lockfiles.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_merge.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_modules_cli.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_pr_comment.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_progress.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_provenance.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_recall.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_remediate.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_report.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_review_eval.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_review_json.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_review_prompts.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_route_auth_fusion.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_sarif.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_sbom.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_scanner.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_security_overlay.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_signal_v2.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_srcpaths.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_suggest.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_suppress.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_taint.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_taint_sanitizer.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_threat_intel.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_threat_model.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_topology.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_triage.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_weaknesses.py +0 -0
- {attackmap-0.4.26 → attackmap-0.4.27}/tests/test_webhardening.py +0 -0
- {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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
- **
|
|
11
|
-
name, version)
|
|
12
|
-
|
|
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
|
-
|
|
153
|
+
entries.append((dep, key))
|
|
154
|
+
unique.setdefault(key, (osv_eco, dep.name, concrete))
|
|
131
155
|
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
419
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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.
|
|
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
|
|
132
|
+
"""Return a transport speaking the OSV batch + detail protocol.
|
|
133
133
|
|
|
134
|
-
``responses_by_body_hint`` keys are substrings
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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)
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
assert summary.cached ==
|
|
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) ==
|
|
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
|
-
|
|
208
|
-
|
|
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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|