attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from .models import Asset, AttackSurface, Control, ControlKind, ControlStrength, ScanResult
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class _ControlPattern:
11
+ kind: ControlKind
12
+ name: str
13
+ strength: ControlStrength
14
+ pattern: re.Pattern[str]
15
+ notes: str | None = None
16
+ # Match only auth-hint labels (not route paths or other hint text), for
17
+ # controls credited by a specific analyzer signal.
18
+ auth_hints_only: bool = False
19
+
20
+
21
+ _CONTROL_PATTERNS: tuple[_ControlPattern, ...] = (
22
+ _ControlPattern(
23
+ kind="authentication",
24
+ name="Password hashing (bcrypt/argon2/scrypt)",
25
+ strength="strong",
26
+ pattern=re.compile(r"\b(bcrypt|argon2|scrypt|pbkdf2)\b", re.IGNORECASE),
27
+ notes="Strong adaptive hash function observed.",
28
+ ),
29
+ _ControlPattern(
30
+ kind="authentication",
31
+ name="JWT verification",
32
+ strength="moderate",
33
+ # Credited only by `jwt_verified` (#243): a verifying call with a pinned
34
+ # algorithm. `jwt.decode` (jsonwebtoken), `sign`, a library import or
35
+ # a `/jwt/...` route path is not verification.
36
+ pattern=re.compile(r"^jwt_verified$"),
37
+ notes="Tokens verified with a pinned algorithm list.",
38
+ auth_hints_only=True,
39
+ ),
40
+ _ControlPattern(
41
+ kind="authentication",
42
+ name="Auth middleware / guards",
43
+ strength="moderate",
44
+ pattern=re.compile(
45
+ r"\b(passport|require[_-]?auth|requireauth|@login_required|@auth\.login_required|authenticate_user|ensureauthenticated|@auth_required|isauthenticated)\b",
46
+ re.IGNORECASE,
47
+ ),
48
+ ),
49
+ _ControlPattern(
50
+ kind="mfa",
51
+ name="Multi-factor authentication",
52
+ strength="strong",
53
+ pattern=re.compile(r"\b(mfa|totp|2fa|webauthn|fido2|otp)\b", re.IGNORECASE),
54
+ ),
55
+ _ControlPattern(
56
+ kind="authorization",
57
+ name="Role / permission checks",
58
+ strength="moderate",
59
+ pattern=re.compile(
60
+ r"\b(haspermission|require_role|requireroles?|@permission_required|@requires_role|hasrole|abilities|policy_check|authorize\()\b",
61
+ re.IGNORECASE,
62
+ ),
63
+ ),
64
+ _ControlPattern(
65
+ kind="rbac",
66
+ name="Role-based access control",
67
+ strength="moderate",
68
+ pattern=re.compile(r"\b(rbac|role_based|access_control_list|acl_check)\b", re.IGNORECASE),
69
+ ),
70
+ _ControlPattern(
71
+ kind="input_validation",
72
+ name="Schema validation library",
73
+ strength="moderate",
74
+ pattern=re.compile(
75
+ r"\b(zod|joi|yup|ajv|pydantic|marshmallow|cerberus|validator\.is|class-validator)\b",
76
+ re.IGNORECASE,
77
+ ),
78
+ ),
79
+ _ControlPattern(
80
+ kind="rate_limiting",
81
+ name="Rate limiting middleware",
82
+ strength="moderate",
83
+ pattern=re.compile(
84
+ r"\b(rate[_-]?limit|express-rate-limit|slowapi|throttle|bottleneck|rack-attack)\b",
85
+ re.IGNORECASE,
86
+ ),
87
+ ),
88
+ _ControlPattern(
89
+ kind="csrf_protection",
90
+ name="CSRF protection",
91
+ strength="moderate",
92
+ pattern=re.compile(
93
+ r"\b(csurf|csrf[_-]?token|csrf_protect|csrfprotect|samesite=(strict|lax))\b",
94
+ re.IGNORECASE,
95
+ ),
96
+ ),
97
+ _ControlPattern(
98
+ kind="encryption_in_transit",
99
+ name="TLS / HTTPS / mTLS",
100
+ strength="moderate",
101
+ pattern=re.compile(r"\b(https://|tls|mtls|sslcontext|ssl_ctx|forcessl|hsts)\b", re.IGNORECASE),
102
+ ),
103
+ _ControlPattern(
104
+ kind="encryption_at_rest",
105
+ name="At-rest encryption / KMS",
106
+ strength="moderate",
107
+ pattern=re.compile(r"\b(kms|envelope_encrypt|aes-256|fernet|libsodium|sealedbox)\b", re.IGNORECASE),
108
+ ),
109
+ _ControlPattern(
110
+ kind="audit_logging",
111
+ name="Audit / security logging",
112
+ strength="moderate",
113
+ pattern=re.compile(
114
+ r"\b(audit_log|security_event|log_security|audit\.log|securityaudit|logaudit)\b",
115
+ re.IGNORECASE,
116
+ ),
117
+ ),
118
+ _ControlPattern(
119
+ kind="security_headers",
120
+ name="Security headers (helmet/secure)",
121
+ strength="moderate",
122
+ pattern=re.compile(r"\b(helmet|secure-headers|csp|content_security_policy|x-frame-options)\b", re.IGNORECASE),
123
+ ),
124
+ _ControlPattern(
125
+ kind="output_encoding",
126
+ name="Output encoding / sanitization",
127
+ strength="moderate",
128
+ pattern=re.compile(r"\b(escape_html|sanitize-html|dompurify|bleach\.clean|markupsafe)\b", re.IGNORECASE),
129
+ ),
130
+ _ControlPattern(
131
+ kind="secret_management",
132
+ name="Vault / secret manager",
133
+ strength="strong",
134
+ pattern=re.compile(r"\b(hashicorp[_-]?vault|aws_secretsmanager|gcp_secretmanager|azure_keyvault|doppler)\b", re.IGNORECASE),
135
+ ),
136
+ )
137
+
138
+
139
+ _SCOPE_HINTS: dict[ControlKind, str] = {
140
+ "authentication": "Routes that read or modify user-scoped data should sit behind this.",
141
+ "authorization": "Privileged or tenant-bounded routes should enforce this.",
142
+ "input_validation": "All untrusted input boundaries should validate request shape.",
143
+ "rate_limiting": "Auth endpoints and write-heavy endpoints should be throttled.",
144
+ "csrf_protection": "Browser-driven state-changing endpoints should require CSRF tokens.",
145
+ "audit_logging": "Critical-asset access and admin actions should be audit-logged.",
146
+ "encryption_in_transit": "All public surfaces should require TLS.",
147
+ "encryption_at_rest": "Sensitive-asset stores should be encrypted.",
148
+ "secret_management": "Long-lived credentials should be sourced from a vault.",
149
+ "mfa": "Privileged flows should require a second factor.",
150
+ }
151
+
152
+
153
+ def _scope_for(asset_kind: str) -> str:
154
+ if asset_kind in {"credentials", "session"}:
155
+ return "asset"
156
+ return "global"
157
+
158
+
159
+ def _all_signal_text(scan: ScanResult) -> list[tuple[str, str, int | None]]:
160
+ """Return (text_blob, file, line) tuples from all hint sources for pattern matching."""
161
+ blobs: list[tuple[str, str, int | None]] = []
162
+ for hint in scan.auth_hints:
163
+ blobs.append((hint.hint, hint.file, hint.line))
164
+ for hint in scan.framework_hints:
165
+ blobs.append((hint.hint, hint.file, hint.line))
166
+ for hint in scan.service_hints:
167
+ blobs.append((hint.hint, hint.file, hint.line))
168
+ for hint in scan.protocol_hints:
169
+ blobs.append((hint.hint, hint.file, hint.line))
170
+ for hint in scan.entrypoint_hints:
171
+ blobs.append((hint.hint, hint.file, hint.line))
172
+ for hint in scan.edge_hints:
173
+ blobs.append((hint.hint, hint.file, hint.line))
174
+ for route in scan.routes:
175
+ blobs.append((route.path, route.file, route.line))
176
+ return blobs
177
+
178
+
179
+ def _format_loc(file_path: str, line: int | None) -> str:
180
+ return f"{file_path}:{line}" if line is not None else file_path
181
+
182
+
183
+ def _detect_present_controls(scan: ScanResult) -> list[Control]:
184
+ blobs = _all_signal_text(scan)
185
+ auth_blobs = [(hint.hint, hint.file, hint.line) for hint in scan.auth_hints]
186
+ controls_by_id: dict[str, Control] = {}
187
+ placements_by_id: dict[str, set[str]] = {}
188
+ evidence_by_id: dict[str, set[str]] = {}
189
+
190
+ for cp in _CONTROL_PATTERNS:
191
+ for text, file_path, line in auth_blobs if cp.auth_hints_only else blobs:
192
+ if cp.pattern.search(text):
193
+ control_id = f"control:{cp.kind}:{cp.name.lower().replace(' ', '_').replace('/', '_')}"
194
+ placements_by_id.setdefault(control_id, set()).add(file_path)
195
+ evidence_by_id.setdefault(control_id, set()).add(f"{text} ({_format_loc(file_path, line)})")
196
+ if control_id not in controls_by_id:
197
+ controls_by_id[control_id] = Control(
198
+ id=control_id,
199
+ kind=cp.kind,
200
+ name=cp.name,
201
+ strength=cp.strength,
202
+ scope="global",
203
+ placements=[],
204
+ evidence=[],
205
+ notes=cp.notes,
206
+ )
207
+
208
+ finalized: list[Control] = []
209
+ for control_id, control in controls_by_id.items():
210
+ placements = sorted(placements_by_id.get(control_id, set()))
211
+ evidence = sorted(evidence_by_id.get(control_id, set()))[:8]
212
+ scope = "global" if len(placements) >= 3 else ("module" if len(placements) > 1 else "route")
213
+ finalized.append(control.model_copy(update={"placements": placements[:12], "evidence": evidence, "scope": scope}))
214
+ finalized.sort(key=lambda c: (c.kind, c.name))
215
+ return finalized
216
+
217
+
218
+ def _detect_absent_controls(
219
+ scan: ScanResult,
220
+ attack_surfaces: list[AttackSurface],
221
+ assets: list[Asset],
222
+ present_kinds: set[ControlKind],
223
+ ) -> list[Control]:
224
+ """Surface controls that are *expected* but not detected anywhere."""
225
+ absences: list[Control] = []
226
+
227
+ has_public_routes = any(s.exposure == "public" for s in attack_surfaces)
228
+ has_admin_or_auth = any(s.category in {"admin", "auth"} for s in attack_surfaces)
229
+ has_state_changing_public = any(
230
+ s.exposure == "public" and s.method.upper() in {"POST", "PUT", "PATCH", "DELETE"}
231
+ for s in attack_surfaces
232
+ )
233
+ sensitive_kinds = {"credentials", "session", "user_pii", "payment"}
234
+ has_sensitive_assets = any(asset.criticality in {"critical", "high"} for asset in assets) or any(
235
+ asset.kind in sensitive_kinds for asset in assets
236
+ )
237
+
238
+ expected: list[tuple[ControlKind, str, str]] = []
239
+ if has_public_routes and "rate_limiting" not in present_kinds:
240
+ expected.append(("rate_limiting", "Rate limiting", "Public routes observed without any rate-limiting markers."))
241
+ if has_admin_or_auth and "authentication" not in present_kinds:
242
+ expected.append(("authentication", "Authentication enforcement", "Admin or auth routes observed without any auth-middleware markers."))
243
+ if has_state_changing_public and "csrf_protection" not in present_kinds:
244
+ expected.append(("csrf_protection", "CSRF protection", "Public state-changing routes observed without CSRF markers."))
245
+ if has_sensitive_assets and "audit_logging" not in present_kinds:
246
+ expected.append(("audit_logging", "Audit logging", "Sensitive assets identified but no audit-logging markers found."))
247
+ if has_sensitive_assets and "encryption_at_rest" not in present_kinds:
248
+ expected.append(("encryption_at_rest", "Encryption at rest", "Sensitive assets identified but no at-rest encryption markers found."))
249
+ if has_admin_or_auth and "mfa" not in present_kinds:
250
+ expected.append(("mfa", "Multi-factor authentication", "Auth/admin surfaces observed without MFA markers."))
251
+
252
+ for kind, name, notes in expected:
253
+ absences.append(
254
+ Control(
255
+ id=f"control:absent:{kind}",
256
+ kind=kind,
257
+ name=name,
258
+ strength="absent",
259
+ scope="global",
260
+ placements=[],
261
+ evidence=[],
262
+ notes=notes,
263
+ )
264
+ )
265
+ return absences
266
+
267
+
268
+ def detect_controls(
269
+ scan: ScanResult,
270
+ attack_surfaces: list[AttackSurface],
271
+ assets: list[Asset],
272
+ ) -> list[Control]:
273
+ present = _detect_present_controls(scan)
274
+ present_kinds: set[ControlKind] = {c.kind for c in present}
275
+ absent = _detect_absent_controls(scan, attack_surfaces, assets, present_kinds)
276
+
277
+ _strength_rank = {"strong": 0, "moderate": 1, "weak": 2, "absent": 3}
278
+ combined = present + absent
279
+ combined.sort(key=lambda c: (_strength_rank.get(c.strength, 9), c.kind, c.name))
280
+ return combined
attackmap/crossrepo.py ADDED
@@ -0,0 +1,356 @@
1
+ """Cross-boundary trust analysis — cross-repo phase 3 (#146c).
2
+
3
+ The confused-deputy shape at a service seam: repo A forwards data across a link
4
+ to repo B (#146b), and B trusts that input — flowing it into a dangerous sink or
5
+ an unguarded object access — *because* it arrives from an internal caller rather
6
+ than a browser. Each repo looks locally fine: A is "just a client", B "only
7
+ serves internal traffic". The bug lives in the gap between them.
8
+
9
+ This pass rides the contract links and reuses each repo's already-computed
10
+ per-repo signals — no new scanning:
11
+
12
+ - **taint basis**: the linked server route reaches an unsanitized dangerous sink
13
+ (a `TaintChain` on that route) — B pipes the caller's value into SQL/exec/etc.
14
+ - **bola basis**: the linked server route is a BOLA/IDOR candidate (an id-bearing
15
+ object access with no ownership check) — B reads/writes the object the caller
16
+ names without checking who is asking.
17
+
18
+ Findings are **speculative**: cross-repo trust is a judgement call (B may
19
+ validate in a way the heuristics miss, or the caller may pre-authorize), so they
20
+ are leads for the #147 verifier to adjudicate, not asserted vulnerabilities.
21
+ Each cites *both* sides — the caller's call site and the callee's sink/route.
22
+
23
+ Pure and deterministic: takes the links plus `(repo_id, ScanResult)` primitives.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+
30
+ from .anomalies import _is_outlier_split
31
+ from .contracts import ContractLink, route_template
32
+ from .models import Route, ScanResult, TaintChain
33
+ from .srcpaths import is_test_file
34
+
35
+ # Sink kinds whose reach from a caller-supplied value is a real cross-boundary
36
+ # risk, split by severity to mirror the single-repo taint policy: injection /
37
+ # code-exec / deserialization / template are HIGH; SSRF, NoSQL and a dynamic
38
+ # file open are MEDIUM (narrower or lower-impact). `sql_execute` is HIGH — across
39
+ # a trust boundary a raw query on caller-supplied data is the classic injection.
40
+ _HIGH_SINKS = frozenset(
41
+ {
42
+ "sql_execute", "subprocess_shell", "eval", "exec", "unsafe_deserialization", "ssti",
43
+ "code_injection", "expression_injection", "jndi_injection", "zip_slip",
44
+ }
45
+ )
46
+ _MEDIUM_SINKS = frozenset(
47
+ {"ssrf", "nosql_injection", "dynamic_open", "path_traversal", "ldap_injection", "xpath_injection"}
48
+ )
49
+ _DANGEROUS_SINKS = _HIGH_SINKS | _MEDIUM_SINKS
50
+
51
+ _WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
52
+
53
+ # One notch down, for the recall-speculative downgrade.
54
+ _LOWER = {"high": "medium", "medium": "low", "low": "low"}
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CrossBoundaryFlow:
59
+ """A confused-deputy flow across a client→server seam."""
60
+
61
+ client_repo: str
62
+ server_repo: str
63
+ method: str
64
+ route: str # the server route the caller reaches
65
+ basis: str # "taint" | "bola"
66
+ detail: str # what B does with the trusted value (sink kind / object access)
67
+ client_target: str
68
+ client_file: str
69
+ client_line: int | None
70
+ server_file: str
71
+ server_line: int | None
72
+ severity: str # "high" | "medium"
73
+
74
+
75
+ def _route_taint(scan: ScanResult, route: str, method: str) -> TaintChain | None:
76
+ """The most-direct unsanitized dangerous sink the given server route reaches."""
77
+ candidates = [
78
+ c
79
+ for c in scan.taint_chains
80
+ if c.route_path == route
81
+ and not c.sanitized
82
+ and c.sink_kind in _DANGEROUS_SINKS
83
+ and _method_ok(method, c.route_method)
84
+ ]
85
+ if not candidates:
86
+ return None
87
+ return min(candidates, key=lambda c: (c.hops, -c.confidence))
88
+
89
+
90
+ def _route_bola(scan: ScanResult, route: str, method: str):
91
+ for cand in scan.authz_candidates:
92
+ if (
93
+ cand.route_path == route
94
+ and not cand.has_ownership_check
95
+ and cand.reaches_db
96
+ # Only a path-param object id is provably the value the caller
97
+ # supplies via the matched path template — a query/rpc/graphql id
98
+ # can't be tied to the client call from path matching alone.
99
+ and cand.surface == "path_param"
100
+ and _method_ok(method, cand.route_method)
101
+ ):
102
+ return cand
103
+ return None
104
+
105
+
106
+ def _carries_path_value(link: ContractLink) -> bool:
107
+ """True when the client forwards a per-request value in the path — the link
108
+ template has a dynamic (`*`) segment. A fully-static call (e.g. a bare
109
+ `/orders` collection) forwards no id, so it can't be a confused-deputy on
110
+ one, even if the server independently taints a query/body value."""
111
+ return "*" in link.path_template.split("/")
112
+
113
+
114
+ def _taint_severity(chain: TaintChain) -> str:
115
+ sev = "high" if chain.sink_kind in _HIGH_SINKS else "medium"
116
+ # Recall-surfaced chains (#148a) are unconfirmed reach — dock a notch.
117
+ return _LOWER[sev] if chain.speculative else sev
118
+
119
+
120
+ def _bola_severity(method: str) -> str:
121
+ # Read-only object reads are medium; state-changing access is high (matches
122
+ # the single-repo BOLA policy).
123
+ return "high" if (method or "").upper() in _WRITE_METHODS else "medium"
124
+
125
+
126
+ def _method_ok(link_method: str, signal_method: str) -> bool:
127
+ lm, sm = (link_method or "ANY").upper(), (signal_method or "ANY").upper()
128
+ return lm in {"", "ANY"} or sm in {"", "ANY"} or lm == sm
129
+
130
+
131
+ def find_cross_boundary_flows(
132
+ links: list[ContractLink], repo_scans: list[tuple[str, ScanResult]]
133
+ ) -> list[CrossBoundaryFlow]:
134
+ """Return confused-deputy flows: a linked server route that trusts the
135
+ caller's value into a dangerous sink (taint) or an unguarded object access
136
+ (bola). Deterministic; deduped on the flow identity."""
137
+ scan_by_repo = dict(repo_scans)
138
+ flows: list[CrossBoundaryFlow] = []
139
+ seen: set[tuple] = set()
140
+ for link in links:
141
+ server_scan = scan_by_repo.get(link.server_repo)
142
+ if server_scan is None:
143
+ continue
144
+ # Provenance gate: the caller must actually forward a path value that the
145
+ # callee's path-based signal consumes — otherwise the "the caller's value
146
+ # reaches the sink" claim is unfounded (a static call + an independent
147
+ # server-side query/body taint is not a confused-deputy).
148
+ if not _carries_path_value(link):
149
+ continue
150
+
151
+ chain = _route_taint(server_scan, link.server_route_path, link.method)
152
+ bola = _route_bola(server_scan, link.server_route_path, link.method)
153
+ if chain is None and bola is None:
154
+ continue
155
+
156
+ if chain is not None:
157
+ basis, detail = "taint", chain.sink_kind
158
+ server_file, server_line = chain.sink_file, chain.sink_line
159
+ severity = _taint_severity(chain)
160
+ else:
161
+ basis, detail = "bola", f"object access on `{bola.id_param}`"
162
+ server_file, server_line = bola.route_file, bola.route_line
163
+ severity = _bola_severity(link.method)
164
+
165
+ key = (link.client_repo, link.server_repo, link.method, link.server_route_path, basis)
166
+ if key in seen:
167
+ continue
168
+ seen.add(key)
169
+ flows.append(
170
+ CrossBoundaryFlow(
171
+ client_repo=link.client_repo,
172
+ server_repo=link.server_repo,
173
+ method=link.method,
174
+ route=link.server_route_path,
175
+ basis=basis,
176
+ detail=detail,
177
+ client_target=link.client_target,
178
+ client_file=link.client_file,
179
+ client_line=link.client_line,
180
+ server_file=server_file,
181
+ server_line=server_line,
182
+ severity=severity,
183
+ )
184
+ )
185
+ flows.sort(key=lambda f: (f.client_repo, f.server_repo, f.route, f.basis))
186
+ return flows
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Trust-assumption gap + cross-repo anomaly (#146d / #149b)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ # repo_id -> {(route file, method, path): has_auth_signal}
194
+ AuthByRepo = dict
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class TrustGap:
199
+ """A mutual-trust gap: repo A makes a state-changing call across a link to a
200
+ route repo B serves with no authentication/authorization control. If A
201
+ assumes B enforces and B assumes only trusted callers reach it, nobody
202
+ does — the action is effectively unauthenticated across the boundary."""
203
+
204
+ client_repo: str
205
+ server_repo: str
206
+ method: str
207
+ route: str
208
+ client_target: str
209
+ client_file: str
210
+ client_line: int | None
211
+ server_file: str
212
+ server_line: int | None
213
+ severity: str = "high"
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class CrossRepoAnomaly:
218
+ """The sibling service that omits a control its peers enforce (#149b): the
219
+ fleet-level odd-one-out. Among repos serving the same resource route, a
220
+ strong majority guard it and this one doesn't."""
221
+
222
+ repo: str
223
+ method: str
224
+ route: str
225
+ template: str
226
+ peers: tuple[str, ...] # sibling repos that DO enforce auth on this route
227
+ severity: str = "medium"
228
+
229
+
230
+ def _matching_route(scan: ScanResult, link: ContractLink) -> Route | None:
231
+ for r in scan.routes:
232
+ if (
233
+ r.path == link.server_route_path
234
+ and r.file == link.server_file
235
+ and _method_ok(link.method, r.method)
236
+ ):
237
+ return r
238
+ return None
239
+
240
+
241
+ def find_trust_gaps(
242
+ links: list[ContractLink],
243
+ repo_scans: list[tuple[str, ScanResult]],
244
+ auth_by_repo: AuthByRepo,
245
+ ) -> list[TrustGap]:
246
+ """State-changing cross-repo calls landing on a server route with no auth
247
+ control. Deterministic; deduped. Speculative — the route may be protected by
248
+ a mechanism the marker heuristics miss (a gateway, mTLS), so these are leads
249
+ for the verifier, not asserted vulnerabilities."""
250
+ scan_by_repo = dict(repo_scans)
251
+ out: list[TrustGap] = []
252
+ seen: set[tuple] = set()
253
+ for link in links:
254
+ server = scan_by_repo.get(link.server_repo)
255
+ if server is None:
256
+ continue
257
+ route = _matching_route(server, link)
258
+ if route is None:
259
+ continue
260
+ # Only state-changing actions — a public read is commonly intentional,
261
+ # so requiring a write keeps this to the high-signal "unauth mutation".
262
+ # When the server route is method-unspecified (`ANY`, e.g. an XRPC
263
+ # surface), fall back to the caller's concrete verb so a known POST/PUT/
264
+ # PATCH/DELETE call still counts.
265
+ route_method = (route.method or "ANY").upper()
266
+ client_method = (link.client_method or "").upper()
267
+ if route_method in _WRITE_METHODS:
268
+ verb = route_method
269
+ elif route_method in {"", "ANY"} and client_method in _WRITE_METHODS:
270
+ verb = client_method
271
+ else:
272
+ continue
273
+ amap = auth_by_repo.get(link.server_repo, {})
274
+ # Default True (assume protected) when unknown — never over-fire on a
275
+ # route we couldn't read.
276
+ if amap.get((route.file, route.method, route.path), True):
277
+ continue
278
+ key = (link.client_repo, link.server_repo, route.method, route.path)
279
+ if key in seen:
280
+ continue
281
+ seen.add(key)
282
+ out.append(
283
+ TrustGap(
284
+ client_repo=link.client_repo,
285
+ server_repo=link.server_repo,
286
+ method=verb,
287
+ route=route.path,
288
+ client_target=link.client_target,
289
+ client_file=link.client_file,
290
+ client_line=link.client_line,
291
+ server_file=route.file,
292
+ server_line=route.line,
293
+ )
294
+ )
295
+ out.sort(key=lambda g: (g.client_repo, g.server_repo, g.route))
296
+ return out
297
+
298
+
299
+ def find_cross_repo_anomalies(
300
+ repo_scans: list[tuple[str, ScanResult]], auth_by_repo: AuthByRepo
301
+ ) -> list[CrossRepoAnomaly]:
302
+ """The fleet-level odd-one-out (#149b): among repos serving the same resource
303
+ route, flag the one that omits an auth control its siblings enforce. A repo
304
+ "enforces" a template when every route it serves under that template carries
305
+ an auth signal. Requires a cohort of ≥3 repos and a strong-majority split, so
306
+ a genuinely-split surface isn't nagged. Speculative."""
307
+ # (template, method) -> repo -> list[has_auth] ; a representative route per
308
+ # (template, method, repo). Method is part of the cohort key so a GET and a
309
+ # POST on the same path aren't treated as the same route (they aren't).
310
+ cohort: dict[tuple[str, str], dict[str, list[bool]]] = {}
311
+ example: dict[tuple[str, str, str], Route] = {}
312
+ for repo, scan in repo_scans:
313
+ amap = auth_by_repo.get(repo, {})
314
+ for r in scan.routes:
315
+ if is_test_file(r.file):
316
+ continue
317
+ template = route_template(r.path)
318
+ if template is None:
319
+ continue
320
+ key = (template, (r.method or "ANY").upper())
321
+ authed = amap.get((r.file, r.method, r.path), False)
322
+ cohort.setdefault(key, {}).setdefault(repo, []).append(authed)
323
+ example.setdefault((*key, repo), r)
324
+
325
+ out: list[CrossRepoAnomaly] = []
326
+ for (template, method), by_repo in cohort.items():
327
+ if len(by_repo) < 3: # need enough siblings to establish a norm
328
+ continue
329
+ enforcing = {repo for repo, flags in by_repo.items() if all(flags)}
330
+ omitting = sorted(set(by_repo) - enforcing)
331
+ if not _is_outlier_split(len(enforcing), len(omitting), len(by_repo)):
332
+ continue
333
+ peers = tuple(sorted(enforcing))
334
+ for repo in omitting:
335
+ r = example[(template, method, repo)]
336
+ out.append(
337
+ CrossRepoAnomaly(
338
+ repo=repo,
339
+ method=method,
340
+ route=r.path,
341
+ template=template,
342
+ peers=peers,
343
+ )
344
+ )
345
+ out.sort(key=lambda a: (a.template, a.method, a.repo))
346
+ return out
347
+
348
+
349
+ __all__ = [
350
+ "CrossBoundaryFlow",
351
+ "CrossRepoAnomaly",
352
+ "TrustGap",
353
+ "find_cross_boundary_flows",
354
+ "find_cross_repo_anomalies",
355
+ "find_trust_gaps",
356
+ ]