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
attackmap/anomalies.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Anomaly / outlier detection — surface the odd-one-out (#78).
|
|
2
|
+
|
|
3
|
+
The closest honest thing to "find the unknown": instead of matching a
|
|
4
|
+
known-bad signature, flag a route that deviates from the norm its own
|
|
5
|
+
siblings establish. If a cohort of routes under the same resource prefix
|
|
6
|
+
all carry an auth signal and one doesn't, that one is worth a human's
|
|
7
|
+
eyes — no CVE, no rule, just an internal inconsistency that often marks a
|
|
8
|
+
forgotten decorator or a copy-paste that dropped a guard.
|
|
9
|
+
|
|
10
|
+
## Heuristics (all peer-group relative)
|
|
11
|
+
|
|
12
|
+
- **auth_outlier**: siblings carry an auth/authorization signal near the
|
|
13
|
+
handler; this route doesn't. (This is the scan-level, route-cohort
|
|
14
|
+
realization of the layered engine's `asymmetric_protection` /
|
|
15
|
+
`control_strength_mismatch` insight — divergent control strength across
|
|
16
|
+
similar surfaces, promoted to an evidence-cited finding.)
|
|
17
|
+
- **validation_outlier**: among a cohort's state-changing handlers, peers
|
|
18
|
+
validate their input and this one shows no validation marker.
|
|
19
|
+
- **method_outlier**: a lone state-changing method exposed in a cohort
|
|
20
|
+
that is otherwise read-only.
|
|
21
|
+
- **invariant_violation** (#149a): the *mined-invariant* pass. Its cohort is
|
|
22
|
+
not a route-path prefix but the set of handlers that reach the same
|
|
23
|
+
dangerous sink kind (from the taint pass). When a strong majority guard the
|
|
24
|
+
request — an auth or validation check *before* the sink — the pass mines
|
|
25
|
+
that as an implicit invariant and flags the handler that reaches the same
|
|
26
|
+
sink kind with no guard before it, citing the mined rule as evidence. This
|
|
27
|
+
is signature-free: it compares the code against itself, so it can surface a
|
|
28
|
+
forgotten guard on a sink pattern no rule anticipates.
|
|
29
|
+
|
|
30
|
+
## Precision
|
|
31
|
+
|
|
32
|
+
Everything is relative to a *consistent* peer group, and confidence
|
|
33
|
+
scales with how consistent it is: a lone deviation among many agreeing
|
|
34
|
+
siblings is more likely a mistake than the same deviation in a group
|
|
35
|
+
that's split. Outliers are only flagged when they're a strict minority,
|
|
36
|
+
so a genuinely 50/50 surface isn't nagged. Per-route signal detection
|
|
37
|
+
reads a small window around the handler (decorators / middleware /
|
|
38
|
+
early-body checks), the same imprecise-but-useful proxy the rest of the
|
|
39
|
+
analyzers use — findings are evidence, not proof.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import re
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import TYPE_CHECKING
|
|
48
|
+
|
|
49
|
+
from .handler_span import span_bounds
|
|
50
|
+
from .safe_fs import contained_file
|
|
51
|
+
from .taint_sinks import kind_label
|
|
52
|
+
from .models import Anomaly, Route, ScanResult
|
|
53
|
+
from .srcpaths import is_test_file
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from .progress import JsonScanProgress, ScanProgress
|
|
57
|
+
|
|
58
|
+
_Progress = ScanProgress | JsonScanProgress
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _SignalCtx:
|
|
63
|
+
"""Shared state for per-route signal lookups within one scan."""
|
|
64
|
+
|
|
65
|
+
root: Path
|
|
66
|
+
line_cache: dict[str, list[str]]
|
|
67
|
+
file_route_lines: dict[str, list[int]]
|
|
68
|
+
|
|
69
|
+
# Auth / authorization signal near a handler. Intentionally broad: a false
|
|
70
|
+
# *presence* only suppresses an outlier (fewer findings), so recall here
|
|
71
|
+
# keeps false positives down. Mirrors authz.py's ownership markers plus
|
|
72
|
+
# framework auth decorators / middleware.
|
|
73
|
+
_AUTH_MARKERS = re.compile(
|
|
74
|
+
r"""
|
|
75
|
+
login_required | @authenticated | ensureAuthenticated | requireAuth | requiresAuth
|
|
76
|
+
| require[s]?_permission | permission_required | permission_classes | IsAuthenticated
|
|
77
|
+
| current_user | currentUser | \brequest\.user\b | \breq\.user\b | \bctx\.user\b
|
|
78
|
+
| authorize | authoriz | \bpolicy\b | \bpolicies\b | \bguard\b | can_access | canAccess
|
|
79
|
+
| has_access | hasAccess | check_access | verify_owner | assert_owner | ensure_owner
|
|
80
|
+
| is_authorized | isAuthorized | @roles? | requireRole | hasRole | @PreAuthorize
|
|
81
|
+
| @Secured | @RolesAllowed | authenticate\s*\( | ensureLoggedIn | verifyToken
|
|
82
|
+
| verify_jwt | jwt_required | @jwt_required | \bmiddleware\b.*auth | auth.*\bmiddleware\b
|
|
83
|
+
| passport\.authenticate | before_action\s*:?\s*:authenticate | check_authentication
|
|
84
|
+
""",
|
|
85
|
+
re.IGNORECASE | re.VERBOSE,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Input-validation signal near a handler.
|
|
89
|
+
_VALIDATION_MARKERS = re.compile(
|
|
90
|
+
r"""
|
|
91
|
+
validate | validation | \bschema\b | pydantic | BaseModel | \bjoi\b | \bzod\b | \byup\b
|
|
92
|
+
| z\.object | Joi\. | \.parse\s*\( | safeParse | serializer | marshmallow | cerberus
|
|
93
|
+
| is_valid\s*\( | \.clean\s*\( | clean_data | sanitize | check_arg | assert_ | expect\s*\(
|
|
94
|
+
| @validate | @Valid | class-validator | express-validator | \bcheck\s*\( | \bbody\s*\(
|
|
95
|
+
""",
|
|
96
|
+
re.IGNORECASE | re.VERBOSE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_READ_METHODS = {"GET", "HEAD", "OPTIONS", "ANY"}
|
|
100
|
+
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
101
|
+
|
|
102
|
+
# Path-parameter forms across frameworks (same as authz.py): {id} :id <id>.
|
|
103
|
+
_PARAM_SEG_RE = re.compile(r"[{:<]|>$|}$")
|
|
104
|
+
|
|
105
|
+
_MAX_FILE_BYTES = 1_000_000
|
|
106
|
+
# A cohort must have at least this many routes to reason about a "norm".
|
|
107
|
+
_MIN_GROUP = 3
|
|
108
|
+
|
|
109
|
+
# Invariant mining (#149a). A sink cohort must have at least this many distinct
|
|
110
|
+
# handlers before a "guard the request before the sink" invariant is credible —
|
|
111
|
+
# higher than `_MIN_GROUP` because a wrong call here points at a dangerous sink.
|
|
112
|
+
_MIN_INVARIANT_COHORT = 4
|
|
113
|
+
|
|
114
|
+
# Human labels for the taint sink kinds an invariant is mined over (mirrors
|
|
115
|
+
# threat_model._TAINT_SINK_LABEL; kept local so anomalies.py stays standalone).
|
|
116
|
+
_SINK_LABELS: dict[str, str] = {
|
|
117
|
+
"sql_execute": "database (SQL execute)",
|
|
118
|
+
"subprocess_shell": "command execution",
|
|
119
|
+
"eval": "code eval",
|
|
120
|
+
"exec": "code exec",
|
|
121
|
+
"dynamic_open": "filesystem open",
|
|
122
|
+
"unsafe_deserialization": "unsafe deserialization",
|
|
123
|
+
"ssti": "template injection",
|
|
124
|
+
"ssrf": "outbound request (SSRF)",
|
|
125
|
+
"nosql_injection": "NoSQL query",
|
|
126
|
+
"open_redirect": "redirect target",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# Leading segments that are transport scaffolding, not the resource itself
|
|
130
|
+
# (`/api`, `/v1`, `/rest`). Stripped so the cohort key lands on the resource
|
|
131
|
+
# root — /api/users, /api/users/<id> and /api/users/export all group as
|
|
132
|
+
# "api/users" — while versioned resources still separate (api/v1/users vs
|
|
133
|
+
# api/v1/orders). `admin`/`internal`/`public` are deliberately NOT here:
|
|
134
|
+
# they mark real trust boundaries worth grouping on.
|
|
135
|
+
_SCAFFOLD_RE = re.compile(r"^(?:api|rest|graphql|gql|v\d+(?:beta\d*|alpha\d*)?)$", re.IGNORECASE)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def find_anomalies(
|
|
139
|
+
scan: ScanResult,
|
|
140
|
+
root: str | Path | None = None,
|
|
141
|
+
progress: "_Progress | None" = None,
|
|
142
|
+
) -> list[Anomaly]:
|
|
143
|
+
"""Return within-repo consistency outliers for the routes in ``scan``.
|
|
144
|
+
|
|
145
|
+
On a large route surface the per-cohort source-window reads dominate this
|
|
146
|
+
pass, so when a ``progress`` reporter is supplied it drives a determinate
|
|
147
|
+
bar over the cohorts (rather than an open-ended spinner).
|
|
148
|
+
"""
|
|
149
|
+
root_path = Path(root or scan.root).resolve()
|
|
150
|
+
|
|
151
|
+
routes = [r for r in scan.routes if not is_test_file(r.file)]
|
|
152
|
+
groups: dict[str, list[Route]] = {}
|
|
153
|
+
for route in routes:
|
|
154
|
+
key = _peer_key(route.path)
|
|
155
|
+
if key:
|
|
156
|
+
groups.setdefault(key, []).append(route)
|
|
157
|
+
|
|
158
|
+
# Per-file line cache (read each source file at most once) and the sorted
|
|
159
|
+
# set of route lines per file, used to bound each handler's signal span.
|
|
160
|
+
line_cache: dict[str, list[str]] = {}
|
|
161
|
+
file_route_lines: dict[str, list[int]] = {}
|
|
162
|
+
for route in routes:
|
|
163
|
+
if route.line is not None:
|
|
164
|
+
file_route_lines.setdefault(route.file, []).append(route.line)
|
|
165
|
+
for lines in file_route_lines.values():
|
|
166
|
+
lines.sort()
|
|
167
|
+
|
|
168
|
+
ctx = _SignalCtx(root_path, line_cache, file_route_lines)
|
|
169
|
+
|
|
170
|
+
if progress is not None:
|
|
171
|
+
progress.begin(len(groups), "Anomaly / outlier detection")
|
|
172
|
+
|
|
173
|
+
out: list[Anomaly] = []
|
|
174
|
+
for key, members in groups.items():
|
|
175
|
+
if progress is not None:
|
|
176
|
+
progress.advance(key)
|
|
177
|
+
if len(members) < _MIN_GROUP:
|
|
178
|
+
continue
|
|
179
|
+
if not _is_route_like_cohort(key, members):
|
|
180
|
+
continue
|
|
181
|
+
out.extend(_auth_outliers(key, members, ctx))
|
|
182
|
+
out.extend(_method_outliers(key, members))
|
|
183
|
+
out.extend(_validation_outliers(key, members, ctx))
|
|
184
|
+
|
|
185
|
+
# Mined-invariant pass (#149a): cohorts of handlers reaching the same
|
|
186
|
+
# dangerous sink, flagged when the majority guard the request before it.
|
|
187
|
+
out.extend(_invariant_violations(scan, ctx))
|
|
188
|
+
|
|
189
|
+
out.sort(key=lambda a: (-a.confidence, a.route_file, a.route_path))
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def route_auth_signals(
|
|
194
|
+
scan: ScanResult, root: str | Path | None = None
|
|
195
|
+
) -> dict[tuple[str, str, str], bool]:
|
|
196
|
+
"""Per-route authentication/authorization presence for one repo.
|
|
197
|
+
|
|
198
|
+
Maps ``(file, method, path)`` → whether an auth/authorization marker appears
|
|
199
|
+
within that route handler's own span (the same span-bounded detection the
|
|
200
|
+
within-repo `auth_outlier` pass uses). Exposed for cross-repo trust analysis
|
|
201
|
+
(#146d): the fleet layer runs it per repo — each with its own root — to spot
|
|
202
|
+
an unauthenticated route reached across a service boundary, and the sibling
|
|
203
|
+
service that omits an auth control its peers enforce."""
|
|
204
|
+
root_path = Path(root or scan.root).resolve()
|
|
205
|
+
routes = [r for r in scan.routes if not is_test_file(r.file)]
|
|
206
|
+
line_cache: dict[str, list[str]] = {}
|
|
207
|
+
file_route_lines: dict[str, list[int]] = {}
|
|
208
|
+
for route in routes:
|
|
209
|
+
if route.line is not None:
|
|
210
|
+
file_route_lines.setdefault(route.file, []).append(route.line)
|
|
211
|
+
for lines in file_route_lines.values():
|
|
212
|
+
lines.sort()
|
|
213
|
+
ctx = _SignalCtx(root_path, line_cache, file_route_lines)
|
|
214
|
+
return {
|
|
215
|
+
(r.file, r.method, r.path): _route_has_signal(r, ctx, _AUTH_MARKERS) for r in routes
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- Heuristics ------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _auth_outliers(key: str, members: list[Route], ctx: _SignalCtx) -> list[Anomaly]:
|
|
223
|
+
with_auth: list[Route] = []
|
|
224
|
+
without_auth: list[Route] = []
|
|
225
|
+
for route in members:
|
|
226
|
+
if _route_has_signal(route, ctx, _AUTH_MARKERS):
|
|
227
|
+
with_auth.append(route)
|
|
228
|
+
else:
|
|
229
|
+
without_auth.append(route)
|
|
230
|
+
|
|
231
|
+
if not _is_outlier_split(len(with_auth), len(without_auth), len(members)):
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
examples = _peer_examples(with_auth)
|
|
235
|
+
return [
|
|
236
|
+
Anomaly(
|
|
237
|
+
kind="auth_outlier",
|
|
238
|
+
route_path=r.path,
|
|
239
|
+
route_method=r.method,
|
|
240
|
+
route_file=r.file,
|
|
241
|
+
route_line=r.line,
|
|
242
|
+
peer_group=key,
|
|
243
|
+
peer_group_size=len(members),
|
|
244
|
+
consistent_peers=len(with_auth),
|
|
245
|
+
deviation=(
|
|
246
|
+
f"{len(with_auth)} of {len(members)} routes under '{key}' carry an "
|
|
247
|
+
f"auth/authorization signal near the handler; this one does not"
|
|
248
|
+
),
|
|
249
|
+
peer_examples=examples,
|
|
250
|
+
severity="high",
|
|
251
|
+
confidence=_confidence(len(with_auth)),
|
|
252
|
+
source_analyzer="anomalies",
|
|
253
|
+
)
|
|
254
|
+
for r in without_auth
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _validation_outliers(key: str, members: list[Route], ctx: _SignalCtx) -> list[Anomaly]:
|
|
259
|
+
# Validation only meaningfully applies to state-changing handlers.
|
|
260
|
+
writers = [r for r in members if r.method.upper() in _WRITE_METHODS]
|
|
261
|
+
if len(writers) < _MIN_GROUP:
|
|
262
|
+
return []
|
|
263
|
+
with_val: list[Route] = []
|
|
264
|
+
without_val: list[Route] = []
|
|
265
|
+
for route in writers:
|
|
266
|
+
if _route_has_signal(route, ctx, _VALIDATION_MARKERS):
|
|
267
|
+
with_val.append(route)
|
|
268
|
+
else:
|
|
269
|
+
without_val.append(route)
|
|
270
|
+
|
|
271
|
+
if not _is_outlier_split(len(with_val), len(without_val), len(writers)):
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
examples = _peer_examples(with_val)
|
|
275
|
+
return [
|
|
276
|
+
Anomaly(
|
|
277
|
+
kind="validation_outlier",
|
|
278
|
+
route_path=r.path,
|
|
279
|
+
route_method=r.method,
|
|
280
|
+
route_file=r.file,
|
|
281
|
+
route_line=r.line,
|
|
282
|
+
peer_group=key,
|
|
283
|
+
peer_group_size=len(writers),
|
|
284
|
+
consistent_peers=len(with_val),
|
|
285
|
+
deviation=(
|
|
286
|
+
f"{len(with_val)} of {len(writers)} state-changing routes under '{key}' "
|
|
287
|
+
f"validate their input; this one shows no validation marker"
|
|
288
|
+
),
|
|
289
|
+
peer_examples=examples,
|
|
290
|
+
severity="low",
|
|
291
|
+
# Validation detection is noisier than auth — cap the ceiling.
|
|
292
|
+
confidence=min(0.7, _confidence(len(with_val))),
|
|
293
|
+
source_analyzer="anomalies",
|
|
294
|
+
)
|
|
295
|
+
for r in without_val
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _method_outliers(key: str, members: list[Route]) -> list[Anomaly]:
|
|
300
|
+
readers = [r for r in members if r.method.upper() in _READ_METHODS]
|
|
301
|
+
writers = [r for r in members if r.method.upper() in _WRITE_METHODS]
|
|
302
|
+
# A lone writer in a cohort that is otherwise a solid read-only cluster.
|
|
303
|
+
# Require a large read-only majority — one POST among a few GETs is just
|
|
304
|
+
# ordinary CRUD, so only a dominant read cluster makes the writer odd.
|
|
305
|
+
if len(writers) != 1 or len(readers) < 4:
|
|
306
|
+
return []
|
|
307
|
+
r = writers[0]
|
|
308
|
+
return [
|
|
309
|
+
Anomaly(
|
|
310
|
+
kind="method_outlier",
|
|
311
|
+
route_path=r.path,
|
|
312
|
+
route_method=r.method,
|
|
313
|
+
route_file=r.file,
|
|
314
|
+
route_line=r.line,
|
|
315
|
+
peer_group=key,
|
|
316
|
+
peer_group_size=len(members),
|
|
317
|
+
consistent_peers=len(readers),
|
|
318
|
+
deviation=(
|
|
319
|
+
f"{len(readers)} of {len(members)} routes under '{key}' are read-only; "
|
|
320
|
+
f"this {r.method} is the only state-changing method in the cohort"
|
|
321
|
+
),
|
|
322
|
+
peer_examples=_peer_examples(readers),
|
|
323
|
+
severity="medium",
|
|
324
|
+
confidence=min(0.7, _confidence(len(readers))),
|
|
325
|
+
source_analyzer="anomalies",
|
|
326
|
+
)
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# --- Mined-invariant pass (#149a) ------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass
|
|
334
|
+
class _InvHandler:
|
|
335
|
+
"""One handler in a sink cohort: its analysis file + declaration line, the
|
|
336
|
+
verbs it is registered under, and the same-file chains it reaches."""
|
|
337
|
+
|
|
338
|
+
file: str
|
|
339
|
+
line: int | None
|
|
340
|
+
path: str
|
|
341
|
+
methods: set[str]
|
|
342
|
+
chains: list
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _invariant_violations(scan: ScanResult, ctx: _SignalCtx) -> list[Anomaly]:
|
|
346
|
+
"""Mine a "guard the request before the sink" invariant per sink kind and
|
|
347
|
+
flag the handler that violates it.
|
|
348
|
+
|
|
349
|
+
The cohort is not a route-path prefix but the set of handlers that reach
|
|
350
|
+
the *same dangerous sink kind* (from ``scan.taint_chains``). When a strong
|
|
351
|
+
majority of those handlers apply an auth/validation guard before the sink,
|
|
352
|
+
that pattern is mined as an implicit invariant and every cohort member that
|
|
353
|
+
reaches the same sink kind with no preceding guard is flagged, citing the
|
|
354
|
+
mined rule. Signature-free: the code is measured against its own norm.
|
|
355
|
+
|
|
356
|
+
Scope decisions that keep the high-severity signal precise:
|
|
357
|
+
|
|
358
|
+
- **Same-file flows only** (``hops == 0``). "Guard before the sink" is a
|
|
359
|
+
statement about ordering, and ordering is only verifiable when the guard
|
|
360
|
+
and the sink live in one file. A downstream (cross-file) sink could be
|
|
361
|
+
called before an ``authorize(...)`` that textually follows it, so we
|
|
362
|
+
don't guess — cross-file chains are simply out of scope here.
|
|
363
|
+
- **Anchored on the chain's own file.** The taint pass reasons from the
|
|
364
|
+
module that *defines* the handler (#107), which for a central-registration
|
|
365
|
+
Node app is not the route's registration file. Analysis therefore anchors
|
|
366
|
+
on ``chain.route_file``; a matching ``Route`` supplies the declaration line
|
|
367
|
+
for a precise per-handler span, and a resolved handler module with no such
|
|
368
|
+
match falls back to a whole-file span.
|
|
369
|
+
- **Per handler, not per verb.** One handler registered for several methods
|
|
370
|
+
emits one ``Route`` per verb; cohort identity is the handler (file +
|
|
371
|
+
declaration line), so multi-verb handlers count once.
|
|
372
|
+
- **Sanitized chains excluded** (#137) — a sink-local neutralizer is a
|
|
373
|
+
different control from a handler guard, so dropping them keeps the cohort
|
|
374
|
+
to genuinely undefended flows.
|
|
375
|
+
"""
|
|
376
|
+
# (file, method, path) -> declaration line, for handlers whose defining file
|
|
377
|
+
# is the file the chain is anchored on (inline / decorator handlers).
|
|
378
|
+
line_by_key: dict[tuple[str, str, str], int] = {}
|
|
379
|
+
for route in scan.routes:
|
|
380
|
+
if is_test_file(route.file) or route.line is None:
|
|
381
|
+
continue
|
|
382
|
+
line_by_key.setdefault((route.file, route.method, route.path), route.line)
|
|
383
|
+
|
|
384
|
+
# sink_kind -> { handler_id -> _InvHandler }
|
|
385
|
+
per_kind: dict[str, dict[tuple[str, object], _InvHandler]] = {}
|
|
386
|
+
for chain in scan.taint_chains:
|
|
387
|
+
# Same-file, undefended flows only (see the scope notes above); for
|
|
388
|
+
# hops == 0 the sink file is the anchor file, so one test covers both.
|
|
389
|
+
# Speculative recall chains (#148a) are unconfirmed leads — don't mine
|
|
390
|
+
# an asserted invariant violation from them.
|
|
391
|
+
if chain.sanitized or chain.speculative or chain.hops != 0:
|
|
392
|
+
continue
|
|
393
|
+
anchor_file = chain.route_file
|
|
394
|
+
if is_test_file(anchor_file):
|
|
395
|
+
continue
|
|
396
|
+
line = line_by_key.get((anchor_file, chain.route_method, chain.route_path))
|
|
397
|
+
# Handler identity: (file, decl line) when known, else (file, path) so a
|
|
398
|
+
# resolved handler module's several verbs still collapse to one handler.
|
|
399
|
+
handler_id: tuple[str, object] = (
|
|
400
|
+
(anchor_file, line) if line is not None else (anchor_file, chain.route_path)
|
|
401
|
+
)
|
|
402
|
+
cohort = per_kind.setdefault(chain.sink_kind, {})
|
|
403
|
+
handler = cohort.get(handler_id)
|
|
404
|
+
if handler is None:
|
|
405
|
+
handler = _InvHandler(
|
|
406
|
+
file=anchor_file, line=line, path=chain.route_path, methods=set(), chains=[]
|
|
407
|
+
)
|
|
408
|
+
cohort[handler_id] = handler
|
|
409
|
+
handler.methods.add(chain.route_method)
|
|
410
|
+
handler.chains.append(chain)
|
|
411
|
+
|
|
412
|
+
out: list[Anomaly] = []
|
|
413
|
+
for kind, cohort in per_kind.items():
|
|
414
|
+
handlers = list(cohort.values())
|
|
415
|
+
if len(handlers) < _MIN_INVARIANT_COHORT:
|
|
416
|
+
continue
|
|
417
|
+
guarded: list[_InvHandler] = []
|
|
418
|
+
unguarded: list[_InvHandler] = []
|
|
419
|
+
for handler in handlers:
|
|
420
|
+
if all(
|
|
421
|
+
_guard_before_sink(handler.file, handler.line, c.sink_file, c.sink_line, ctx)
|
|
422
|
+
for c in handler.chains
|
|
423
|
+
):
|
|
424
|
+
guarded.append(handler)
|
|
425
|
+
else:
|
|
426
|
+
unguarded.append(handler)
|
|
427
|
+
if not _is_outlier_split(len(guarded), len(unguarded), len(handlers)):
|
|
428
|
+
continue
|
|
429
|
+
label = _SINK_LABELS.get(kind) or kind_label(kind)
|
|
430
|
+
invariant = (
|
|
431
|
+
f"{len(guarded)} of {len(handlers)} handlers that reach a {label} sink "
|
|
432
|
+
f"apply an auth/validation guard before it"
|
|
433
|
+
)
|
|
434
|
+
examples = _handler_examples(guarded)
|
|
435
|
+
for handler in unguarded:
|
|
436
|
+
out.append(
|
|
437
|
+
Anomaly(
|
|
438
|
+
kind="invariant_violation",
|
|
439
|
+
route_path=handler.path,
|
|
440
|
+
route_method="/".join(sorted(handler.methods)),
|
|
441
|
+
route_file=handler.file,
|
|
442
|
+
route_line=handler.line,
|
|
443
|
+
peer_group=f"sink:{kind}",
|
|
444
|
+
peer_group_size=len(handlers),
|
|
445
|
+
consistent_peers=len(guarded),
|
|
446
|
+
deviation=(
|
|
447
|
+
f"reaches a {label} sink with no auth/validation guard "
|
|
448
|
+
f"before it, unlike its sibling handlers"
|
|
449
|
+
),
|
|
450
|
+
invariant=invariant,
|
|
451
|
+
peer_examples=examples,
|
|
452
|
+
severity="high",
|
|
453
|
+
confidence=_confidence(len(guarded)),
|
|
454
|
+
source_analyzer="anomalies",
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
return out
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _handler_examples(handlers: list[_InvHandler], limit: int = 3) -> list[str]:
|
|
461
|
+
seen: list[str] = []
|
|
462
|
+
for h in handlers:
|
|
463
|
+
label = f"{'/'.join(sorted(h.methods))} {h.path}"
|
|
464
|
+
if label not in seen:
|
|
465
|
+
seen.append(label)
|
|
466
|
+
if len(seen) >= limit:
|
|
467
|
+
break
|
|
468
|
+
return seen
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _guard_before_sink(
|
|
472
|
+
file: str, line: int | None, sink_file: str, sink_line: int | None, ctx: _SignalCtx
|
|
473
|
+
) -> bool:
|
|
474
|
+
"""True if an auth/validation marker guards the handler *before* the sink.
|
|
475
|
+
|
|
476
|
+
The handler span is its declaration line down to the next route (``line``
|
|
477
|
+
known), or the whole file when the handler is a resolved module with no
|
|
478
|
+
matching route declaration. The sink is same-file (this pass only reasons
|
|
479
|
+
about ``hops == 0`` flows): when it sits inside the span the window is
|
|
480
|
+
tightened to strictly above the sink line — a guard placed *after* the sink
|
|
481
|
+
doesn't defend it — otherwise (a sink in another handler's body, reached via
|
|
482
|
+
same-file import fan-out) the whole span counts.
|
|
483
|
+
"""
|
|
484
|
+
lines = _file_lines(file, ctx)
|
|
485
|
+
if not lines:
|
|
486
|
+
return False
|
|
487
|
+
if line is None:
|
|
488
|
+
start, end = 0, len(lines)
|
|
489
|
+
else:
|
|
490
|
+
start, end = span_bounds(line, lines, ctx.file_route_lines.get(file, []))
|
|
491
|
+
# sink_line is 1-based; index sink_line-1 is the sink itself. Only tighten
|
|
492
|
+
# the window to "before the sink" when the sink sits within this span.
|
|
493
|
+
if sink_file == file and sink_line is not None and start < sink_line - 1 < end:
|
|
494
|
+
end = sink_line - 1
|
|
495
|
+
if end <= start:
|
|
496
|
+
return False
|
|
497
|
+
window = "\n".join(lines[start:end])
|
|
498
|
+
return (
|
|
499
|
+
_AUTH_MARKERS.search(window) is not None
|
|
500
|
+
or _VALIDATION_MARKERS.search(window) is not None
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# --- Internals -------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _is_route_like_cohort(key: str, members: list[Route]) -> bool:
|
|
508
|
+
"""Reject cohorts that are route-extractor noise rather than a real
|
|
509
|
+
resource surface.
|
|
510
|
+
|
|
511
|
+
Method-call strings the extractor mistakes for routes (``headers.delete
|
|
512
|
+
('content-type')`` → ``DELETE /content-type``, ``params.get('request_uri')``
|
|
513
|
+
→ ``GET /request_uri``) collapse to a single bare path repeated across
|
|
514
|
+
files. A genuine resource cohort instead has *structural variety*: more
|
|
515
|
+
than one distinct path, and at least one member that carries a path
|
|
516
|
+
parameter or descends below the cohort key. Requiring both keeps real
|
|
517
|
+
surfaces (``/api/users`` + ``/api/users/{id}``) while dropping the noise.
|
|
518
|
+
"""
|
|
519
|
+
distinct_paths = {r.path for r in members}
|
|
520
|
+
if len(distinct_paths) < 2:
|
|
521
|
+
return False
|
|
522
|
+
key_depth = key.count("/") + 1
|
|
523
|
+
for path in distinct_paths:
|
|
524
|
+
if _PARAM_SEG_RE.search(path):
|
|
525
|
+
return True
|
|
526
|
+
if len([s for s in path.split("/") if s]) > key_depth:
|
|
527
|
+
return True
|
|
528
|
+
return False
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _is_outlier_split(consistent: int, deviating: int, total: int) -> bool:
|
|
532
|
+
"""True when ``deviating`` routes are a strict, small minority against a
|
|
533
|
+
consistent majority — the shape of a real odd-one-out."""
|
|
534
|
+
if consistent < 2 or deviating < 1:
|
|
535
|
+
return False
|
|
536
|
+
# Outliers may be at most a quarter of the cohort (min 1), so a split
|
|
537
|
+
# group isn't nagged and a large consistent group can tolerate a couple.
|
|
538
|
+
return deviating <= max(1, total // 4)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _confidence(consistent_peers: int) -> float:
|
|
542
|
+
"""Scale confidence with the size of the agreeing peer group."""
|
|
543
|
+
return round(min(0.9, 0.45 + 0.09 * consistent_peers), 2)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _peer_key(path: str) -> str | None:
|
|
547
|
+
"""Cohort key: scaffolding prefix (``api``/version) plus the resource
|
|
548
|
+
root — the first static segment past the scaffolding. ``None`` if there
|
|
549
|
+
is no static prefix (e.g. ``/{id}``), which has no resource cohort.
|
|
550
|
+
|
|
551
|
+
Examples: ``/api/users/{id}`` → ``api/users``; ``/api/users/export`` →
|
|
552
|
+
``api/users``; ``/api/v1/orders/{id}`` → ``api/v1/orders``."""
|
|
553
|
+
static: list[str] = []
|
|
554
|
+
for seg in path.split("/"):
|
|
555
|
+
if not seg:
|
|
556
|
+
continue
|
|
557
|
+
if _PARAM_SEG_RE.search(seg):
|
|
558
|
+
break
|
|
559
|
+
static.append(seg)
|
|
560
|
+
if not static:
|
|
561
|
+
return None
|
|
562
|
+
i = 0
|
|
563
|
+
while i < len(static) and _SCAFFOLD_RE.match(static[i]):
|
|
564
|
+
i += 1
|
|
565
|
+
# scaffolding + the resource root (or just the scaffolding if the whole
|
|
566
|
+
# static prefix was scaffolding, e.g. a bare `/api`).
|
|
567
|
+
key_parts = static[: i + 1] if i < len(static) else static
|
|
568
|
+
return "/".join(key_parts) or None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _peer_examples(routes: list[Route], limit: int = 3) -> list[str]:
|
|
572
|
+
seen: list[str] = []
|
|
573
|
+
for r in routes:
|
|
574
|
+
label = f"{r.method} {r.path}"
|
|
575
|
+
if label not in seen:
|
|
576
|
+
seen.append(label)
|
|
577
|
+
if len(seen) >= limit:
|
|
578
|
+
break
|
|
579
|
+
return seen
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _route_has_signal(route: Route, ctx: _SignalCtx, marker: re.Pattern[str]) -> bool:
|
|
583
|
+
"""True if ``marker`` appears within the route handler's own span.
|
|
584
|
+
|
|
585
|
+
The span is the handler's line range — from its declaration (extended
|
|
586
|
+
upward over contiguous ``@decorator`` lines) down to just before the
|
|
587
|
+
next route in the same file — so a sibling handler's guard is never
|
|
588
|
+
mistaken for this one's. With no line info, fall back to the whole file."""
|
|
589
|
+
lines = _file_lines(route.file, ctx)
|
|
590
|
+
if not lines:
|
|
591
|
+
return False
|
|
592
|
+
if route.line is None:
|
|
593
|
+
return marker.search("\n".join(lines)) is not None
|
|
594
|
+
start, end = span_bounds(route.line, lines, ctx.file_route_lines.get(route.file, []))
|
|
595
|
+
return marker.search("\n".join(lines[start:end])) is not None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _file_lines(rel_file: str, ctx: _SignalCtx) -> list[str]:
|
|
599
|
+
if rel_file in ctx.line_cache:
|
|
600
|
+
return ctx.line_cache[rel_file]
|
|
601
|
+
path = contained_file(ctx.root, rel_file)
|
|
602
|
+
lines: list[str] = []
|
|
603
|
+
try:
|
|
604
|
+
if path is not None and path.stat().st_size <= _MAX_FILE_BYTES:
|
|
605
|
+
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
606
|
+
except (OSError, ValueError):
|
|
607
|
+
lines = []
|
|
608
|
+
ctx.line_cache[rel_file] = lines
|
|
609
|
+
return lines
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
__all__ = ["find_anomalies", "route_auth_signals"]
|