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.

Files changed (119) hide show
  1. {attackmap-0.4.21/src/attackmap.egg-info → attackmap-0.4.25}/PKG-INFO +26 -1
  2. {attackmap-0.4.21 → attackmap-0.4.25}/README.md +25 -0
  3. {attackmap-0.4.21 → attackmap-0.4.25}/pyproject.toml +1 -1
  4. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/__init__.py +1 -1
  5. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/analyzers.py +5 -1
  6. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/anomalies.py +27 -1
  7. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/cli.py +198 -6
  8. attackmap-0.4.25/src/attackmap/contracts.py +201 -0
  9. attackmap-0.4.25/src/attackmap/crossrepo.py +351 -0
  10. attackmap-0.4.25/src/attackmap/fleet.py +358 -0
  11. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/merge.py +4 -1
  12. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/models.py +5 -0
  13. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/scanner.py +31 -4
  14. {attackmap-0.4.21 → attackmap-0.4.25/src/attackmap.egg-info}/PKG-INFO +26 -1
  15. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/SOURCES.txt +6 -0
  16. attackmap-0.4.25/tests/test_contracts.py +194 -0
  17. attackmap-0.4.25/tests/test_crossrepo.py +328 -0
  18. attackmap-0.4.25/tests/test_fleet.py +319 -0
  19. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_merge.py +11 -5
  20. {attackmap-0.4.21 → attackmap-0.4.25}/LICENSE +0 -0
  21. {attackmap-0.4.21 → attackmap-0.4.25}/setup.cfg +0 -0
  22. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/analyzer.py +0 -0
  23. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/analyzer_contracts.py +0 -0
  24. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/asset_model.py +0 -0
  25. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/attack_taxonomy.py +0 -0
  26. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/authz.py +0 -0
  27. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/config_scanner.py +0 -0
  28. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/context_pack.py +0 -0
  29. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/control_model.py +0 -0
  30. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/crypto.py +0 -0
  31. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/cve.py +0 -0
  32. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/defensive_review.py +0 -0
  33. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/detection_opportunities.py +0 -0
  34. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/diagrams.py +0 -0
  35. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/diff.py +0 -0
  36. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/exploitability.py +0 -0
  37. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/graph.py +0 -0
  38. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/hunt_harness.py +0 -0
  39. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/insights.py +0 -0
  40. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/llm_review.py +0 -0
  41. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/lockfiles.py +0 -0
  42. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/progress.py +0 -0
  43. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/recon_models.py +0 -0
  44. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/recon_to_analysis.py +0 -0
  45. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/report.py +0 -0
  46. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/review_eval.py +0 -0
  47. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/review_json.py +0 -0
  48. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/review_prompts.py +0 -0
  49. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/route_auth_fusion.py +0 -0
  50. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sarif.py +0 -0
  51. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sbom.py +0 -0
  52. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sdk/__init__.py +0 -0
  53. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sdk/contracts.py +0 -0
  54. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/sdk/models.py +0 -0
  55. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/security_overlay.py +0 -0
  56. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/srcpaths.py +0 -0
  57. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/suggest.py +0 -0
  58. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/suppress.py +0 -0
  59. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/taint.py +0 -0
  60. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/threat_model.py +0 -0
  61. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/topology.py +0 -0
  62. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/triage.py +0 -0
  63. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/weaknesses.py +0 -0
  64. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/webhardening.py +0 -0
  65. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap/workflow_scanner.py +0 -0
  66. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/dependency_links.txt +0 -0
  67. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/entry_points.txt +0 -0
  68. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/requires.txt +0 -0
  69. {attackmap-0.4.21 → attackmap-0.4.25}/src/attackmap.egg-info/top_level.txt +0 -0
  70. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_analyzer.py +0 -0
  71. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_analyzer_metadata_schema.py +0 -0
  72. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_analyzers.py +0 -0
  73. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_anomalies.py +0 -0
  74. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_artifact_schemas.py +0 -0
  75. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_authz.py +0 -0
  76. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_config_scanner.py +0 -0
  77. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_context_pack.py +0 -0
  78. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_crypto.py +0 -0
  79. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_cve.py +0 -0
  80. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_defensive_review.py +0 -0
  81. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_diagrams.py +0 -0
  82. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_diff.py +0 -0
  83. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_exploitability.py +0 -0
  84. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_graph.py +0 -0
  85. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_hunt.py +0 -0
  86. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_hunt_harness.py +0 -0
  87. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_injection_sinks.py +0 -0
  88. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_llm_review.py +0 -0
  89. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_lockfiles.py +0 -0
  90. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_modules_cli.py +0 -0
  91. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_pr_comment.py +0 -0
  92. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_progress.py +0 -0
  93. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_provenance.py +0 -0
  94. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_recall.py +0 -0
  95. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_recon_to_analysis.py +0 -0
  96. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_remediate.py +0 -0
  97. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_report.py +0 -0
  98. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_review_eval.py +0 -0
  99. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_review_json.py +0 -0
  100. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_review_prompts.py +0 -0
  101. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_route_auth_fusion.py +0 -0
  102. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_sarif.py +0 -0
  103. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_sbom.py +0 -0
  104. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_scanner.py +0 -0
  105. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_security_overlay.py +0 -0
  106. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_shared_contract_imports.py +0 -0
  107. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_signal_v2.py +0 -0
  108. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_srcpaths.py +0 -0
  109. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_suggest.py +0 -0
  110. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_suppress.py +0 -0
  111. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_taint.py +0 -0
  112. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_taint_sanitizer.py +0 -0
  113. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_threat_intel.py +0 -0
  114. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_threat_model.py +0 -0
  115. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_topology.py +0 -0
  116. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_triage.py +0 -0
  117. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_weaknesses.py +0 -0
  118. {attackmap-0.4.21 → attackmap-0.4.25}/tests/test_webhardening.py +0 -0
  119. {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.21
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.21"
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.21"
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
- calls.append(ExternalCall(target=match.groups()[-1], file=context.relative_path))
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
- path: str = typer.Argument(".", help="Path to the repository to analyze."),
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
- repo_path = Path(path).resolve()
202
- if not repo_path.exists():
203
- raise typer.BadParameter(f"Path does not exist: {repo_path}")
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
- if progress_format not in {"auto", "tty", "json", "none"}:
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"]