attackmap 0.4.21__tar.gz → 0.4.25__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.21/src/attackmap.egg-info → attackmap-0.4.25}/PKG-INFO +26 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/README.md +25 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/pyproject.toml +1 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/__init__.py +1 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/analyzers.py +5 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/anomalies.py +27 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/cli.py +198 -6
- attackmap-0.4.25/src/attackmap/contracts.py +201 -0
- attackmap-0.4.25/src/attackmap/crossrepo.py +351 -0
- attackmap-0.4.25/src/attackmap/fleet.py +358 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/merge.py +4 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/models.py +5 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/scanner.py +31 -4
- {attackmap-0.4.21 → attackmap-0.4.25/src/attackmap.egg-info}/PKG-INFO +26 -1
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/SOURCES.txt +6 -0
- attackmap-0.4.25/tests/test_contracts.py +194 -0
- attackmap-0.4.25/tests/test_crossrepo.py +328 -0
- attackmap-0.4.25/tests/test_fleet.py +319 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_merge.py +11 -5
- {attackmap-0.4.21 → attackmap-0.4.25}/LICENSE +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/setup.cfg +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/authz.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/config_scanner.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/control_model.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/crypto.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/cve.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/diagrams.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/diff.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/exploitability.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/graph.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/hunt_harness.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/insights.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/llm_review.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/lockfiles.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/progress.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/report.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/review_json.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/route_auth_fusion.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sarif.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sbom.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sdk/__init__.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/srcpaths.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/suggest.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/suppress.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/taint.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/threat_model.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/topology.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/triage.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/weaknesses.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/webhardening.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/workflow_scanner.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/requires.txt +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_analyzer.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_analyzer_metadata_schema.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_analyzers.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_anomalies.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_authz.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_config_scanner.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_context_pack.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_crypto.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_cve.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_defensive_review.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_diagrams.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_diff.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_exploitability.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_graph.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_hunt.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_hunt_harness.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_injection_sinks.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_llm_review.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_lockfiles.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_modules_cli.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_pr_comment.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_progress.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_provenance.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_recall.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_remediate.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_report.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_review_eval.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_review_json.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_review_prompts.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_route_auth_fusion.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_sarif.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_sbom.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_scanner.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_security_overlay.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_signal_v2.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_srcpaths.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_suggest.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_suppress.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_taint.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_taint_sanitizer.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_threat_intel.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_threat_model.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_topology.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_triage.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_weaknesses.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_webhardening.py +0 -0
- {attackmap-0.4.21 → attackmap-0.4.25}/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.25
|
|
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>
|
|
@@ -128,6 +128,31 @@ model ID verbatim.
|
|
|
128
128
|
Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm.md`
|
|
129
129
|
(LLM-narrated) side by side.
|
|
130
130
|
|
|
131
|
+
**Multi-repo (fleet) scan.** Pass two or more repositories to scan a whole fleet
|
|
132
|
+
in one run:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
attackmap analyze ./service-a ./service-b ./gateway --output reports
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Each repo is scanned independently into its own `reports/<repo>/` directory, and
|
|
139
|
+
a `reports/fleet-summary.md` (+ `.json`) indexes the run with per-repo severity
|
|
140
|
+
counts and top findings. AttackMap then **links the seams**: it matches one
|
|
141
|
+
repo's outbound HTTP calls to another repo's routes (by normalized path template
|
|
142
|
+
+ method — `base + "/api/orders/" + id` in a caller aligns with `/api/orders/{id}`
|
|
143
|
+
served elsewhere), lists each client→server link (citing both sides) in the fleet
|
|
144
|
+
summary, and renders the repo-to-repo graph to `reports/fleet-graph.md`.
|
|
145
|
+
|
|
146
|
+
Over those links it then flags **cross-boundary trust** (confused-deputy) risks:
|
|
147
|
+
a value one repo forwards across a link that the callee trusts into a dangerous
|
|
148
|
+
sink or an unguarded object access — each repo looks locally fine; the bug is in
|
|
149
|
+
the seam. These are marked **speculative** (leads to adjudicate, citing both the
|
|
150
|
+
caller and callee sites) and listed in the fleet summary. It also flags
|
|
151
|
+
**trust-assumption gaps** (a state-changing call crossing a link to a route the
|
|
152
|
+
callee serves with no auth — if each side assumes the other enforces, nobody
|
|
153
|
+
does) and **cross-repo anomalies** (the one service that omits an auth control
|
|
154
|
+
its siblings enforce on the same route). Single-repo behavior is unchanged.
|
|
155
|
+
|
|
131
156
|
---
|
|
132
157
|
|
|
133
158
|
## Install
|
|
@@ -67,6 +67,31 @@ model ID verbatim.
|
|
|
67
67
|
Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm.md`
|
|
68
68
|
(LLM-narrated) side by side.
|
|
69
69
|
|
|
70
|
+
**Multi-repo (fleet) scan.** Pass two or more repositories to scan a whole fleet
|
|
71
|
+
in one run:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
attackmap analyze ./service-a ./service-b ./gateway --output reports
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each repo is scanned independently into its own `reports/<repo>/` directory, and
|
|
78
|
+
a `reports/fleet-summary.md` (+ `.json`) indexes the run with per-repo severity
|
|
79
|
+
counts and top findings. AttackMap then **links the seams**: it matches one
|
|
80
|
+
repo's outbound HTTP calls to another repo's routes (by normalized path template
|
|
81
|
+
+ method — `base + "/api/orders/" + id` in a caller aligns with `/api/orders/{id}`
|
|
82
|
+
served elsewhere), lists each client→server link (citing both sides) in the fleet
|
|
83
|
+
summary, and renders the repo-to-repo graph to `reports/fleet-graph.md`.
|
|
84
|
+
|
|
85
|
+
Over those links it then flags **cross-boundary trust** (confused-deputy) risks:
|
|
86
|
+
a value one repo forwards across a link that the callee trusts into a dangerous
|
|
87
|
+
sink or an unguarded object access — each repo looks locally fine; the bug is in
|
|
88
|
+
the seam. These are marked **speculative** (leads to adjudicate, citing both the
|
|
89
|
+
caller and callee sites) and listed in the fleet summary. It also flags
|
|
90
|
+
**trust-assumption gaps** (a state-changing call crossing a link to a route the
|
|
91
|
+
callee serves with no auth — if each side assumes the other enforces, nobody
|
|
92
|
+
does) and **cross-repo anomalies** (the one service that omits an auth control
|
|
93
|
+
its siblings enforce on the same route). Single-repo behavior is unchanged.
|
|
94
|
+
|
|
70
95
|
---
|
|
71
96
|
|
|
72
97
|
## Install
|
|
@@ -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.25"
|
|
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"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.25"
|
|
@@ -47,6 +47,7 @@ from .scanner import (
|
|
|
47
47
|
DB_KEYWORDS,
|
|
48
48
|
DB_PATTERNS,
|
|
49
49
|
EXTERNAL_CALL_PATTERNS,
|
|
50
|
+
_external_call_fields,
|
|
50
51
|
SECRET_PATTERNS,
|
|
51
52
|
extract_routes,
|
|
52
53
|
scan_repo,
|
|
@@ -104,7 +105,10 @@ class ExternalCallAnalyzer:
|
|
|
104
105
|
calls: list[ExternalCall] = []
|
|
105
106
|
for pattern in EXTERNAL_CALL_PATTERNS:
|
|
106
107
|
for match in pattern.finditer(context.content):
|
|
107
|
-
|
|
108
|
+
target, method = _external_call_fields(match)
|
|
109
|
+
calls.append(
|
|
110
|
+
ExternalCall(target=target, method=method, file=context.relative_path)
|
|
111
|
+
)
|
|
108
112
|
return AnalyzerSignals(external_calls=calls)
|
|
109
113
|
|
|
110
114
|
|
|
@@ -195,6 +195,32 @@ def find_anomalies(
|
|
|
195
195
|
return out
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
def route_auth_signals(
|
|
199
|
+
scan: ScanResult, root: str | Path | None = None
|
|
200
|
+
) -> dict[tuple[str, str, str], bool]:
|
|
201
|
+
"""Per-route authentication/authorization presence for one repo.
|
|
202
|
+
|
|
203
|
+
Maps ``(file, method, path)`` → whether an auth/authorization marker appears
|
|
204
|
+
within that route handler's own span (the same span-bounded detection the
|
|
205
|
+
within-repo `auth_outlier` pass uses). Exposed for cross-repo trust analysis
|
|
206
|
+
(#146d): the fleet layer runs it per repo — each with its own root — to spot
|
|
207
|
+
an unauthenticated route reached across a service boundary, and the sibling
|
|
208
|
+
service that omits an auth control its peers enforce."""
|
|
209
|
+
root_path = Path(root or scan.root).resolve()
|
|
210
|
+
routes = [r for r in scan.routes if not is_test_file(r.file)]
|
|
211
|
+
line_cache: dict[str, list[str]] = {}
|
|
212
|
+
file_route_lines: dict[str, list[int]] = {}
|
|
213
|
+
for route in routes:
|
|
214
|
+
if route.line is not None:
|
|
215
|
+
file_route_lines.setdefault(route.file, []).append(route.line)
|
|
216
|
+
for lines in file_route_lines.values():
|
|
217
|
+
lines.sort()
|
|
218
|
+
ctx = _SignalCtx(root_path, line_cache, file_route_lines)
|
|
219
|
+
return {
|
|
220
|
+
(r.file, r.method, r.path): _route_has_signal(r, ctx, _AUTH_MARKERS) for r in routes
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
198
224
|
# --- Heuristics ------------------------------------------------------------
|
|
199
225
|
|
|
200
226
|
|
|
@@ -608,4 +634,4 @@ def _file_lines(rel_file: str, ctx: _SignalCtx) -> list[str]:
|
|
|
608
634
|
return lines
|
|
609
635
|
|
|
610
636
|
|
|
611
|
-
__all__ = ["find_anomalies"]
|
|
637
|
+
__all__ = ["find_anomalies", "route_auth_signals"]
|
|
@@ -17,6 +17,21 @@ from .analyzers import (
|
|
|
17
17
|
select_requested_analyzers,
|
|
18
18
|
)
|
|
19
19
|
from .cve import query_vulnerabilities
|
|
20
|
+
from .anomalies import route_auth_signals
|
|
21
|
+
from .contracts import link_contracts
|
|
22
|
+
from .crossrepo import (
|
|
23
|
+
find_cross_boundary_flows,
|
|
24
|
+
find_cross_repo_anomalies,
|
|
25
|
+
find_trust_gaps,
|
|
26
|
+
)
|
|
27
|
+
from .fleet import (
|
|
28
|
+
FleetRepoResult,
|
|
29
|
+
FleetScan,
|
|
30
|
+
fleet_repo_ids,
|
|
31
|
+
fleet_summary_json,
|
|
32
|
+
render_fleet_graph_mermaid,
|
|
33
|
+
render_fleet_summary,
|
|
34
|
+
)
|
|
20
35
|
from .diff import (
|
|
21
36
|
FindingSnapshot,
|
|
22
37
|
diff_findings,
|
|
@@ -66,9 +81,151 @@ TRIAGE_BANNER = (
|
|
|
66
81
|
)
|
|
67
82
|
|
|
68
83
|
|
|
84
|
+
def _run_fleet(
|
|
85
|
+
repo_paths: list[Path],
|
|
86
|
+
*,
|
|
87
|
+
output: str,
|
|
88
|
+
module: list[str] | None,
|
|
89
|
+
recall: bool,
|
|
90
|
+
cve: bool,
|
|
91
|
+
no_suppress: bool,
|
|
92
|
+
suppress_file: str | None,
|
|
93
|
+
progress_format: str,
|
|
94
|
+
no_progress: bool,
|
|
95
|
+
fleet_incompatible: dict[str, bool],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Multi-repo fleet scan (#146a). Scans each repo independently — reusing the
|
|
98
|
+
same building blocks a single-repo run uses — writes per-repo reports into
|
|
99
|
+
``output/<repo_id>/``, and assembles a fleet summary. No cross-repo detection
|
|
100
|
+
yet (that is #146b–#146d); this is the foundation."""
|
|
101
|
+
active_flags = [flag for flag, on in fleet_incompatible.items() if on]
|
|
102
|
+
if active_flags:
|
|
103
|
+
raise typer.BadParameter(
|
|
104
|
+
f"{', '.join(sorted(active_flags))} not yet supported in multi-repo mode — "
|
|
105
|
+
"run one repository at a time for these (fleet-aware wiring lands in #146b+)."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
selected_analyzers = None
|
|
109
|
+
if module:
|
|
110
|
+
try:
|
|
111
|
+
selected_analyzers = select_requested_analyzers(module, auto_install=True)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
114
|
+
|
|
115
|
+
output_root = Path(output)
|
|
116
|
+
repo_ids = fleet_repo_ids(repo_paths)
|
|
117
|
+
fleet = FleetScan()
|
|
118
|
+
|
|
119
|
+
for repo_id, repo_path in zip(repo_ids, repo_paths):
|
|
120
|
+
typer.echo("")
|
|
121
|
+
typer.echo(f"── {repo_id} ({repo_path}) ──")
|
|
122
|
+
active_analyzers = resolve_run_analyzers(repo_path, analyzers=selected_analyzers)
|
|
123
|
+
scan_progress = create_progress(progress_format, no_progress=no_progress)
|
|
124
|
+
scan = analyze_repository(
|
|
125
|
+
repo_path, analyzers=active_analyzers, progress=scan_progress, recall=recall
|
|
126
|
+
)
|
|
127
|
+
if cve and scan.dependencies:
|
|
128
|
+
try:
|
|
129
|
+
vulns, _cve_summary = query_vulnerabilities(scan.dependencies)
|
|
130
|
+
finally:
|
|
131
|
+
scan_progress.done()
|
|
132
|
+
scan.vulnerabilities = vulns
|
|
133
|
+
|
|
134
|
+
graph = build_graph(scan)
|
|
135
|
+
analysis = translate_recon(scan)
|
|
136
|
+
attack_surfaces = analysis.attack_surfaces
|
|
137
|
+
findings = analysis.findings
|
|
138
|
+
attack_paths = analysis.attack_paths
|
|
139
|
+
architecture_md = summarize_architecture(scan, graph)
|
|
140
|
+
attack_surface_md = summarize_attack_surface(scan, attack_surfaces)
|
|
141
|
+
|
|
142
|
+
suppressed_findings: list = []
|
|
143
|
+
if not no_suppress:
|
|
144
|
+
explicit = Path(suppress_file) if suppress_file else None
|
|
145
|
+
if explicit is not None and not explicit.exists():
|
|
146
|
+
raise typer.BadParameter(f"Suppress file not found: {explicit}")
|
|
147
|
+
suppset, sup_warnings = collect_suppressions(repo_path, findings, explicit_file=explicit)
|
|
148
|
+
for warning in sup_warnings:
|
|
149
|
+
typer.echo(f"Suppression warning: {warning}", err=True)
|
|
150
|
+
outcome = apply_suppressions(findings, suppset)
|
|
151
|
+
findings = outcome.active
|
|
152
|
+
suppressed_findings = outcome.suppressed
|
|
153
|
+
|
|
154
|
+
defensive_review_md = render_defensive_review(scan, attack_surfaces, findings, attack_paths)
|
|
155
|
+
repo_out = output_root / repo_id
|
|
156
|
+
write_reports(
|
|
157
|
+
repo_out,
|
|
158
|
+
scan,
|
|
159
|
+
architecture_md,
|
|
160
|
+
attack_surface_md,
|
|
161
|
+
defensive_review_md,
|
|
162
|
+
attack_surfaces,
|
|
163
|
+
findings,
|
|
164
|
+
attack_paths,
|
|
165
|
+
analyzer_metadata=[
|
|
166
|
+
{
|
|
167
|
+
"name": metadata.name,
|
|
168
|
+
"description": metadata.description,
|
|
169
|
+
"scope": metadata.scope,
|
|
170
|
+
"ecosystems": list(metadata.ecosystems),
|
|
171
|
+
}
|
|
172
|
+
for metadata in (get_analyzer_metadata(a) for a in active_analyzers)
|
|
173
|
+
],
|
|
174
|
+
suppressed=suppressed_findings,
|
|
175
|
+
)
|
|
176
|
+
typer.echo(render_console_summary(scan, findings, attack_paths))
|
|
177
|
+
fleet.results.append(
|
|
178
|
+
FleetRepoResult(
|
|
179
|
+
repo_id=repo_id,
|
|
180
|
+
root=str(repo_path),
|
|
181
|
+
report_dir=str(repo_out.resolve()),
|
|
182
|
+
scan=scan,
|
|
183
|
+
findings=findings,
|
|
184
|
+
attack_paths=attack_paths,
|
|
185
|
+
suppressed_count=len(suppressed_findings),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Cross-repo contract linking (#146b): match one repo's outbound calls to
|
|
190
|
+
# another repo's routes, over the assembled fleet.
|
|
191
|
+
repo_scans = [(r.repo_id, r.scan) for r in fleet.results]
|
|
192
|
+
fleet.links = link_contracts(repo_scans)
|
|
193
|
+
# Cross-boundary trust flows (#146c): confused-deputy leads over the links.
|
|
194
|
+
fleet.cross_boundary = find_cross_boundary_flows(fleet.links, repo_scans)
|
|
195
|
+
# Trust-assumption gaps + cross-repo anomalies (#146d / #149b). Per-route auth
|
|
196
|
+
# signals are resolved per repo (each with its own root).
|
|
197
|
+
auth_by_repo = {r.repo_id: route_auth_signals(r.scan, r.root) for r in fleet.results}
|
|
198
|
+
fleet.trust_gaps = find_trust_gaps(fleet.links, repo_scans, auth_by_repo)
|
|
199
|
+
fleet.cross_repo_anomalies = find_cross_repo_anomalies(repo_scans, auth_by_repo)
|
|
200
|
+
|
|
201
|
+
output_root.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
(output_root / "fleet-summary.md").write_text(
|
|
203
|
+
render_fleet_summary(fleet) + "\n", encoding="utf-8"
|
|
204
|
+
)
|
|
205
|
+
(output_root / "fleet-summary.json").write_text(
|
|
206
|
+
json.dumps(fleet_summary_json(fleet), indent=2) + "\n", encoding="utf-8"
|
|
207
|
+
)
|
|
208
|
+
(output_root / "fleet-graph.md").write_text(
|
|
209
|
+
render_fleet_graph_mermaid(fleet) + "\n", encoding="utf-8"
|
|
210
|
+
)
|
|
211
|
+
typer.echo("")
|
|
212
|
+
typer.echo(
|
|
213
|
+
f"Fleet: {fleet.repo_count} repositories, {fleet.total_findings()} finding(s), "
|
|
214
|
+
f"{len(fleet.links)} cross-repo link(s), "
|
|
215
|
+
f"{len(fleet.cross_boundary)} cross-boundary + {len(fleet.trust_gaps)} trust-gap + "
|
|
216
|
+
f"{len(fleet.cross_repo_anomalies)} anomaly (all speculative). "
|
|
217
|
+
f"Summary written to: {(output_root / 'fleet-summary.md').resolve()}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
69
221
|
@app.command()
|
|
70
222
|
def analyze(
|
|
71
|
-
|
|
223
|
+
paths: list[str] | None = typer.Argument(
|
|
224
|
+
None,
|
|
225
|
+
help="Path(s) to the repository(ies) to analyze. Pass two or more for a "
|
|
226
|
+
"cross-repo fleet scan (#146a): each is scanned into its own report "
|
|
227
|
+
"subdirectory and a fleet summary is written alongside. Defaults to '.'.",
|
|
228
|
+
),
|
|
72
229
|
output: str = typer.Option("reports", "--output", "-o", help="Directory for generated reports."),
|
|
73
230
|
format: str = typer.Option("all", "--format", help="Output format: all, markdown, or json."),
|
|
74
231
|
module: list[str] | None = typer.Option(
|
|
@@ -198,9 +355,45 @@ def analyze(
|
|
|
198
355
|
help="Path to a suppression baseline, overriding auto-discovery of .attackmap-suppress.yaml at the repo root (#144).",
|
|
199
356
|
),
|
|
200
357
|
) -> None:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
358
|
+
repo_paths = [Path(p).resolve() for p in (paths or ["."])]
|
|
359
|
+
for rp in repo_paths:
|
|
360
|
+
if not rp.exists():
|
|
361
|
+
raise typer.BadParameter(f"Path does not exist: {rp}")
|
|
362
|
+
|
|
363
|
+
# Validate progress format before either branch, so the fleet path enforces
|
|
364
|
+
# the same option semantics as a single-repo run (#146a).
|
|
365
|
+
if progress_format not in {"auto", "tty", "json", "none"}:
|
|
366
|
+
raise typer.BadParameter("--progress-format must be one of: auto, json, none.")
|
|
367
|
+
|
|
368
|
+
# Multi-repo fleet mode (#146a): scan each repo independently and assemble a
|
|
369
|
+
# fleet view. Dispatched here so the single-repo path below is untouched.
|
|
370
|
+
if len(repo_paths) > 1:
|
|
371
|
+
_run_fleet(
|
|
372
|
+
repo_paths,
|
|
373
|
+
output=output,
|
|
374
|
+
module=module,
|
|
375
|
+
recall=recall,
|
|
376
|
+
cve=cve,
|
|
377
|
+
no_suppress=no_suppress,
|
|
378
|
+
suppress_file=suppress_file,
|
|
379
|
+
progress_format=progress_format,
|
|
380
|
+
no_progress=no_progress,
|
|
381
|
+
# Single-repo-only features aren't fleet-aware yet — reject rather
|
|
382
|
+
# than silently ignore, so the user isn't surprised (#146b+ wire them).
|
|
383
|
+
fleet_incompatible={
|
|
384
|
+
"--baseline": baseline is not None,
|
|
385
|
+
"--diff-output": diff_output is not None,
|
|
386
|
+
"--fail-on-new-high": fail_on_new_high,
|
|
387
|
+
"--pr-comment": pr_comment is not None,
|
|
388
|
+
"--llm": llm,
|
|
389
|
+
"--hunt": hunt,
|
|
390
|
+
"--remediate": remediate,
|
|
391
|
+
"--triage": triage,
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
repo_path = repo_paths[0]
|
|
204
397
|
# Validate diff-mode flag combinations before doing any real work.
|
|
205
398
|
if fail_on_new_high and baseline is None:
|
|
206
399
|
raise typer.BadParameter("--fail-on-new-high requires --baseline to be set.")
|
|
@@ -216,8 +409,7 @@ def analyze(
|
|
|
216
409
|
except ValueError as exc:
|
|
217
410
|
raise typer.BadParameter(str(exc)) from exc
|
|
218
411
|
|
|
219
|
-
|
|
220
|
-
raise typer.BadParameter("--progress-format must be one of: auto, json, none.")
|
|
412
|
+
# (--progress-format is validated up-front, before the fleet dispatch.)
|
|
221
413
|
if llm_speed not in {"standard", "fast"}:
|
|
222
414
|
raise typer.BadParameter("--llm-speed must be one of: standard, fast.")
|
|
223
415
|
if llm_provider not in {"claude", "openai"}:
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Cross-repo contract linking — cross-repo phase 2 (#146b).
|
|
2
|
+
|
|
3
|
+
Given a fleet of independently-scanned repos (#146a), link one repo's **outbound
|
|
4
|
+
HTTP calls** to another repo's **routes** — the client↔server seam. This is the
|
|
5
|
+
one linking dimension that reuses signals both sides already produce
|
|
6
|
+
(`ExternalCall.target`/`.method` and `Route.path`/`.method`); the richer
|
|
7
|
+
dimensions (RPC callers, shared schemas, queues, DB tables, token iss/aud) are
|
|
8
|
+
new extraction deferred to later work.
|
|
9
|
+
|
|
10
|
+
Matching is by **normalized path template + method**, cross-repo only:
|
|
11
|
+
|
|
12
|
+
- The call's URL path and the route's path are each normalized to a segment
|
|
13
|
+
tuple with concrete ids and path params collapsed to ``*`` (so
|
|
14
|
+
``/users/123`` ↔ ``/users/{id}`` ↔ ``/users/:id`` align).
|
|
15
|
+
- A link requires the normalized paths to be **equal** and the methods
|
|
16
|
+
compatible (equal, or either side unknown/``ANY``).
|
|
17
|
+
- Precision guards: the path must carry at least one **static** segment (a bare
|
|
18
|
+
``/{id}`` won't link), infra/static routes are excluded, and client and
|
|
19
|
+
server must be **different** repos.
|
|
20
|
+
|
|
21
|
+
Pure and deterministic — takes ``(repo_id, ScanResult)`` primitives so it stays
|
|
22
|
+
unit-testable without a real scan and imports nothing from the CLI/fleet layer.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
from .models import ExternalCall, Route, ScanResult
|
|
32
|
+
from .srcpaths import is_infra_route, is_test_file, is_vendored_file
|
|
33
|
+
|
|
34
|
+
# A path segment that is a concrete instance id rather than a static resource
|
|
35
|
+
# name: an explicit path param (`{id}`, `:id`, `<id>`), an all-digits segment,
|
|
36
|
+
# a uuid, or a long hex blob. Collapsed to `*` so caller and server templates
|
|
37
|
+
# align regardless of how each side writes the variable part.
|
|
38
|
+
_PARAM_SEG = re.compile(r"^([{:<].*|.*[}>]|\d+|[0-9a-fA-F-]{16,}|[0-9a-fA-F]{8,})$")
|
|
39
|
+
_PLACEHOLDER = "*"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ContractLink:
|
|
44
|
+
"""A matched client→server seam across two repos."""
|
|
45
|
+
|
|
46
|
+
client_repo: str
|
|
47
|
+
server_repo: str
|
|
48
|
+
method: str # the route's method (the served contract)
|
|
49
|
+
path_template: str # normalized template, e.g. "users/*"
|
|
50
|
+
client_target: str # the raw outbound URL as written in the caller
|
|
51
|
+
client_file: str
|
|
52
|
+
client_line: int | None
|
|
53
|
+
server_route_path: str # the route path as declared on the server
|
|
54
|
+
server_file: str
|
|
55
|
+
server_line: int | None
|
|
56
|
+
# The caller's concrete verb — retained so a state-changing call to a
|
|
57
|
+
# method-unspecified (`ANY`) server route (e.g. XRPC surfaces) isn't lost by
|
|
58
|
+
# downstream trust-gap analysis (#146d). `None` when unknown (config URL).
|
|
59
|
+
client_method: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_path(path: str, *, client: bool = False) -> tuple[str, ...] | None:
|
|
63
|
+
"""Normalize a URL/route path to a segment tuple with instance ids collapsed
|
|
64
|
+
to ``*``. Returns ``None`` when there is nothing linkable (no path, or only a
|
|
65
|
+
root / all-variable path with no static segment to anchor on).
|
|
66
|
+
|
|
67
|
+
``client=True`` handles the concatenation idiom: an outbound URL built as
|
|
68
|
+
``base + "/api/orders/" + oid`` is captured only up to the trailing slash, so
|
|
69
|
+
a client path ending in ``/`` is treated as having a dynamic final segment
|
|
70
|
+
(``api/orders/`` → ``("api","orders","*")``) — which then aligns with the
|
|
71
|
+
server template ``/api/orders/{id}`` while still separating from the bare
|
|
72
|
+
collection route ``/api/orders``."""
|
|
73
|
+
if not path:
|
|
74
|
+
return None
|
|
75
|
+
# A full URL (client side) — take just the path component.
|
|
76
|
+
if "://" in path:
|
|
77
|
+
path = urlsplit(path).path
|
|
78
|
+
else:
|
|
79
|
+
path = path.split("?", 1)[0].split("#", 1)[0]
|
|
80
|
+
trailing_slash = path.endswith("/") and path.strip() not in {"", "/"}
|
|
81
|
+
segments = [s for s in path.split("/") if s]
|
|
82
|
+
if not segments:
|
|
83
|
+
return None
|
|
84
|
+
norm = [_PLACEHOLDER if _PARAM_SEG.match(s) else s.lower() for s in segments]
|
|
85
|
+
if client and trailing_slash:
|
|
86
|
+
norm.append(_PLACEHOLDER) # the concatenated dynamic segment
|
|
87
|
+
# Require at least one static segment — a bare `/{id}` or `/*/*` is too
|
|
88
|
+
# generic to link on without producing spurious cross-repo edges.
|
|
89
|
+
if all(s == _PLACEHOLDER for s in norm):
|
|
90
|
+
return None
|
|
91
|
+
return tuple(norm)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _method_compatible(call_method: str | None, route_method: str) -> bool:
|
|
95
|
+
"""Methods match when they're equal, or either side is unknown/ANY."""
|
|
96
|
+
rm = (route_method or "ANY").upper()
|
|
97
|
+
if call_method is None or rm in {"", "ANY"}:
|
|
98
|
+
return True
|
|
99
|
+
return call_method.upper() == rm
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _excluded_source(rel_file: str) -> bool:
|
|
103
|
+
"""Test/spec/fixture and vendored/generated files are not production
|
|
104
|
+
architecture — a cross-repo link anchored on one is noise. Honors the same
|
|
105
|
+
`ATTACKMAP_INCLUDE_TESTS`/`_VENDORED` opt-ins as the rest of the scanner."""
|
|
106
|
+
return is_test_file(rel_file) or is_vendored_file(rel_file)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _linkable_calls(scan: ScanResult) -> list[tuple[ExternalCall, tuple[str, ...]]]:
|
|
110
|
+
out: list[tuple[ExternalCall, tuple[str, ...]]] = []
|
|
111
|
+
for call in scan.external_calls:
|
|
112
|
+
if _excluded_source(call.file):
|
|
113
|
+
continue
|
|
114
|
+
norm = _normalize_path(call.target, client=True)
|
|
115
|
+
if norm is None:
|
|
116
|
+
continue
|
|
117
|
+
if is_infra_route("/" + "/".join(norm)):
|
|
118
|
+
continue
|
|
119
|
+
out.append((call, norm))
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _linkable_routes(scan: ScanResult) -> list[tuple[Route, tuple[str, ...]]]:
|
|
124
|
+
out: list[tuple[Route, tuple[str, ...]]] = []
|
|
125
|
+
for route in scan.routes:
|
|
126
|
+
if _excluded_source(route.file):
|
|
127
|
+
continue
|
|
128
|
+
if is_infra_route(route.path):
|
|
129
|
+
continue
|
|
130
|
+
norm = _normalize_path(route.path)
|
|
131
|
+
if norm is None:
|
|
132
|
+
continue
|
|
133
|
+
out.append((route, norm))
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def link_contracts(repo_scans: list[tuple[str, ScanResult]]) -> list[ContractLink]:
|
|
138
|
+
"""Return the client→server contract links across a fleet of repos.
|
|
139
|
+
|
|
140
|
+
For every ordered pair of *distinct* repos (A as client, B as server), match
|
|
141
|
+
A's linkable outbound calls to B's linkable routes by normalized path +
|
|
142
|
+
method. Deterministic; deduped on the full link identity.
|
|
143
|
+
"""
|
|
144
|
+
calls_by_repo = {rid: _linkable_calls(scan) for rid, scan in repo_scans}
|
|
145
|
+
routes_by_repo = {rid: _linkable_routes(scan) for rid, scan in repo_scans}
|
|
146
|
+
|
|
147
|
+
links: list[ContractLink] = []
|
|
148
|
+
seen: set[tuple] = set()
|
|
149
|
+
for client_id, _client_scan in repo_scans:
|
|
150
|
+
for call, call_norm in calls_by_repo[client_id]:
|
|
151
|
+
for server_id, _server_scan in repo_scans:
|
|
152
|
+
if server_id == client_id:
|
|
153
|
+
continue
|
|
154
|
+
for route, route_norm in routes_by_repo[server_id]:
|
|
155
|
+
if call_norm != route_norm:
|
|
156
|
+
continue
|
|
157
|
+
if not _method_compatible(call.method, route.method):
|
|
158
|
+
continue
|
|
159
|
+
key = (
|
|
160
|
+
client_id,
|
|
161
|
+
server_id,
|
|
162
|
+
call.file,
|
|
163
|
+
call.line,
|
|
164
|
+
route.file,
|
|
165
|
+
route.method,
|
|
166
|
+
route.path,
|
|
167
|
+
)
|
|
168
|
+
if key in seen:
|
|
169
|
+
continue
|
|
170
|
+
seen.add(key)
|
|
171
|
+
links.append(
|
|
172
|
+
ContractLink(
|
|
173
|
+
client_repo=client_id,
|
|
174
|
+
server_repo=server_id,
|
|
175
|
+
method=(route.method or "ANY").upper(),
|
|
176
|
+
path_template="/".join(call_norm),
|
|
177
|
+
client_target=call.target,
|
|
178
|
+
client_file=call.file,
|
|
179
|
+
client_line=call.line,
|
|
180
|
+
server_route_path=route.path,
|
|
181
|
+
server_file=route.file,
|
|
182
|
+
server_line=route.line,
|
|
183
|
+
client_method=(call.method.upper() if call.method else None),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
links.sort(
|
|
187
|
+
key=lambda link: (link.client_repo, link.server_repo, link.path_template, link.method)
|
|
188
|
+
)
|
|
189
|
+
return links
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def route_template(path: str) -> str | None:
|
|
193
|
+
"""Public helper: the normalized template for a route path (instance ids
|
|
194
|
+
collapsed to ``*``), or ``None`` if too generic to key on. Used by the
|
|
195
|
+
cross-repo anomaly cohorting (#146d) to group the *same* resource route
|
|
196
|
+
across repos."""
|
|
197
|
+
norm = _normalize_path(path)
|
|
198
|
+
return "/".join(norm) if norm is not None else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["ContractLink", "link_contracts", "route_template"]
|