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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|