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,1221 @@
|
|
|
1
|
+
"""Generate detection-engineering hints from insights and findings.
|
|
2
|
+
|
|
3
|
+
For each notable observation, suggest a runtime signal a defender could
|
|
4
|
+
add to catch the same condition in production. Insight-level opportunities
|
|
5
|
+
are prose rule sketches. Opportunities generated per finding class (#251:
|
|
6
|
+
taint/injection routes, SSRF, BOLA, webhook signatures, CI workflows, leaked
|
|
7
|
+
AWS keys, route-less code weaknesses) also carry a deployable Sigma rule with its log source, required fields, false-positive
|
|
8
|
+
notes and sample events, rendered from the templates in
|
|
9
|
+
:mod:`attackmap.detections`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .attack_taxonomy import techniques_for_finding, techniques_for_insight
|
|
19
|
+
from .detections import (
|
|
20
|
+
RenderedRule,
|
|
21
|
+
has_path_params,
|
|
22
|
+
regex_literal,
|
|
23
|
+
render_rule,
|
|
24
|
+
route_example,
|
|
25
|
+
route_scopable,
|
|
26
|
+
route_selector,
|
|
27
|
+
rule_uuid,
|
|
28
|
+
sigma_method,
|
|
29
|
+
stem_selector,
|
|
30
|
+
template_data,
|
|
31
|
+
)
|
|
32
|
+
from .detections.sigma import correlation_problems, sigma_matches, sigma_problems
|
|
33
|
+
from .taint_sinks import canonical_kind
|
|
34
|
+
from .taxonomy import ATTACK_TECHNIQUES, INFORMATIONAL_RULES, attack_url, taxonomy_for
|
|
35
|
+
from .models import (
|
|
36
|
+
AttackSurface,
|
|
37
|
+
AttackTechnique,
|
|
38
|
+
BolaCandidate,
|
|
39
|
+
DetectionLogsource,
|
|
40
|
+
DetectionOpportunity,
|
|
41
|
+
DetectionTestEvents,
|
|
42
|
+
Finding,
|
|
43
|
+
Insight,
|
|
44
|
+
InsightKind,
|
|
45
|
+
ScanResult,
|
|
46
|
+
TaintChain,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _opportunity(
|
|
51
|
+
*,
|
|
52
|
+
opportunity_id: str,
|
|
53
|
+
title: str,
|
|
54
|
+
rationale: str,
|
|
55
|
+
signal_kind: str,
|
|
56
|
+
suggested_rule: str,
|
|
57
|
+
related_insight_ids: list[str] | None = None,
|
|
58
|
+
related_finding_titles: list[str] | None = None,
|
|
59
|
+
related_rule_ids: list[str] | None = None,
|
|
60
|
+
attack_techniques: list[AttackTechnique] | None = None,
|
|
61
|
+
) -> DetectionOpportunity:
|
|
62
|
+
return DetectionOpportunity(
|
|
63
|
+
id=opportunity_id,
|
|
64
|
+
title=title,
|
|
65
|
+
rationale=rationale,
|
|
66
|
+
signal_kind=signal_kind, # type: ignore[arg-type]
|
|
67
|
+
suggested_rule=suggested_rule,
|
|
68
|
+
related_insight_ids=related_insight_ids or [],
|
|
69
|
+
related_finding_titles=related_finding_titles or [],
|
|
70
|
+
related_rule_ids=related_rule_ids or [],
|
|
71
|
+
attack_techniques=attack_techniques or [],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- Per-insight-kind opportunity generators ----------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _opp_for_shared_secret_blast_radius(insight: Insight) -> DetectionOpportunity:
|
|
79
|
+
return _opportunity(
|
|
80
|
+
opportunity_id=f"detect:{insight.id}",
|
|
81
|
+
title="Detect signing-key drift across services",
|
|
82
|
+
rationale=(
|
|
83
|
+
"Shared signing material across many services means a single rotation should be visible "
|
|
84
|
+
"everywhere; conversely, divergence may indicate a compromised key in use."
|
|
85
|
+
),
|
|
86
|
+
signal_kind="config_audit",
|
|
87
|
+
suggested_rule=(
|
|
88
|
+
"Periodically hash the secret material referenced by each service and alert when "
|
|
89
|
+
"different services report different hashes for the same secret name (or when one "
|
|
90
|
+
"service uses a key version older than N days). Pair with token-issuer telemetry: "
|
|
91
|
+
"alert on tokens signed by a key version not present in the current authorized set."
|
|
92
|
+
),
|
|
93
|
+
related_insight_ids=[insight.id],
|
|
94
|
+
attack_techniques=techniques_for_insight(insight),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _opp_for_sensitive_asset_reachability(insight: Insight) -> DetectionOpportunity:
|
|
99
|
+
routes = ", ".join(f"`{r}`" for r in insight.related_routes[:3]) or "the listed routes"
|
|
100
|
+
return _opportunity(
|
|
101
|
+
opportunity_id=f"detect:{insight.id}",
|
|
102
|
+
title="Alert on unauthenticated access to sensitive-asset routes",
|
|
103
|
+
rationale=(
|
|
104
|
+
"A static-analysis gap on auth at the surface is also a runtime detection opportunity: "
|
|
105
|
+
"any 2xx response for these routes from a request lacking a valid session/JWT is high signal."
|
|
106
|
+
),
|
|
107
|
+
signal_kind="log",
|
|
108
|
+
suggested_rule=(
|
|
109
|
+
f"On the access log: alert when status_code IN (200,201,204) AND route IN ({routes}) "
|
|
110
|
+
"AND request_headers.authorization IS NULL AND session_cookie IS NULL. "
|
|
111
|
+
"Tighten with a low-volume baseline — this should normally be zero."
|
|
112
|
+
),
|
|
113
|
+
related_insight_ids=[insight.id],
|
|
114
|
+
attack_techniques=techniques_for_insight(insight),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _opp_for_admin_action_without_auth(insight: Insight) -> DetectionOpportunity:
|
|
119
|
+
routes = ", ".join(f"`{r}`" for r in insight.related_routes[:3]) or "admin routes"
|
|
120
|
+
return _opportunity(
|
|
121
|
+
opportunity_id=f"detect:{insight.id}",
|
|
122
|
+
title="Alert on any unauthenticated state change to admin/role routes",
|
|
123
|
+
rationale=(
|
|
124
|
+
"Privilege-mutation flows are zero-tolerance for unauthenticated calls. Any successful "
|
|
125
|
+
"mutating verb against them from an unauthenticated session should page on first occurrence."
|
|
126
|
+
),
|
|
127
|
+
signal_kind="log",
|
|
128
|
+
suggested_rule=(
|
|
129
|
+
f"On the access log: alert when method IN (POST,PUT,PATCH,DELETE) AND route IN ({routes}) "
|
|
130
|
+
"AND auth_subject IS NULL. Severity: page-on-first. Pair with an audit-log assertion that "
|
|
131
|
+
"every successful call to these routes carries an actor + role-change record."
|
|
132
|
+
),
|
|
133
|
+
related_insight_ids=[insight.id],
|
|
134
|
+
attack_techniques=techniques_for_insight(insight),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _opp_for_audit_gap(insight: Insight) -> DetectionOpportunity:
|
|
139
|
+
return _opportunity(
|
|
140
|
+
opportunity_id=f"detect:{insight.id}",
|
|
141
|
+
title="Establish baseline audit logs on sensitive-asset access",
|
|
142
|
+
rationale=(
|
|
143
|
+
"Detection is only as good as the telemetry available. The lack of audit-logging signals "
|
|
144
|
+
"in code is a precondition: there is nothing for a SIEM to reason over."
|
|
145
|
+
),
|
|
146
|
+
signal_kind="log",
|
|
147
|
+
suggested_rule=(
|
|
148
|
+
"Emit a structured audit record `{actor, action, asset_id, outcome, request_id}` from "
|
|
149
|
+
"every handler that touches a sensitive asset. Forward to SIEM. Once the stream exists, "
|
|
150
|
+
"build per-actor anomaly detection (asset access at unusual hours, asset access from new IPs, "
|
|
151
|
+
"asset access volume above the per-actor 95th percentile)."
|
|
152
|
+
),
|
|
153
|
+
related_insight_ids=[insight.id],
|
|
154
|
+
attack_techniques=techniques_for_insight(insight),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _opp_for_defense_gap_in_chain(insight: Insight) -> DetectionOpportunity:
|
|
159
|
+
return _opportunity(
|
|
160
|
+
opportunity_id=f"detect:{insight.id}",
|
|
161
|
+
title="Add request-tracing to confirm controls actually run on this chain",
|
|
162
|
+
rationale=(
|
|
163
|
+
"Static analysis can't tell whether a control is invoked at runtime. Trace spans on the "
|
|
164
|
+
"entry and sink confirm whether intermediate auth/validation/rate-limit middleware actually executed."
|
|
165
|
+
),
|
|
166
|
+
signal_kind="trace",
|
|
167
|
+
suggested_rule=(
|
|
168
|
+
"Instrument the chain's entry handler and its sink (DB call) with OpenTelemetry spans. "
|
|
169
|
+
"Alert when a sink span's parent trace lacks a span tagged `middleware=auth` or "
|
|
170
|
+
"`middleware=ratelimit`. This catches code paths that bypass middleware via direct invocation."
|
|
171
|
+
),
|
|
172
|
+
related_insight_ids=[insight.id],
|
|
173
|
+
attack_techniques=techniques_for_insight(insight),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _opp_for_control_strength_mismatch(insight: Insight) -> DetectionOpportunity:
|
|
178
|
+
return _opportunity(
|
|
179
|
+
opportunity_id=f"detect:{insight.id}",
|
|
180
|
+
title="Add config-drift detection for missing controls on critical assets",
|
|
181
|
+
rationale=(
|
|
182
|
+
"When critical assets exist but expected controls aren't observable, drift detection on "
|
|
183
|
+
"the deployed configuration (WAF rules, IAM policies, encryption-at-rest flags) gives "
|
|
184
|
+
"ongoing visibility even before code-level fixes land."
|
|
185
|
+
),
|
|
186
|
+
signal_kind="config_audit",
|
|
187
|
+
suggested_rule=(
|
|
188
|
+
"Add a daily IaC/config audit step: assert that the modules holding critical assets have "
|
|
189
|
+
"(a) a WAF/auth policy attached, (b) database encryption-at-rest enabled, (c) audit-log "
|
|
190
|
+
"subscriptions configured. Page when any assertion regresses."
|
|
191
|
+
),
|
|
192
|
+
related_insight_ids=[insight.id],
|
|
193
|
+
attack_techniques=techniques_for_insight(insight),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _opp_for_asymmetric_protection(insight: Insight) -> DetectionOpportunity:
|
|
198
|
+
return _opportunity(
|
|
199
|
+
opportunity_id=f"detect:{insight.id}",
|
|
200
|
+
title="Compare per-method auth-rejection rates on the same route",
|
|
201
|
+
rationale=(
|
|
202
|
+
"Asymmetric protection often shows up as a spike in unauthenticated traffic to the "
|
|
203
|
+
"unprotected verb of a route whose other verbs require auth — attackers probe for the gap."
|
|
204
|
+
),
|
|
205
|
+
signal_kind="metric",
|
|
206
|
+
suggested_rule=(
|
|
207
|
+
"Per route, alert when the ratio of (unauthenticated requests on verb X) / "
|
|
208
|
+
"(unauthenticated requests on verb Y) exceeds 10x for routes that share a path. "
|
|
209
|
+
"Suggests probing of asymmetric protection."
|
|
210
|
+
),
|
|
211
|
+
related_insight_ids=[insight.id],
|
|
212
|
+
attack_techniques=techniques_for_insight(insight),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _opp_for_single_point_of_failure(insight: Insight) -> DetectionOpportunity:
|
|
217
|
+
return _opportunity(
|
|
218
|
+
opportunity_id=f"detect:{insight.id}",
|
|
219
|
+
title="Track the blast-radius secret's last-rotated timestamp",
|
|
220
|
+
rationale=(
|
|
221
|
+
"A single auth-critical secret with no rotation cadence is a slow-burn risk; detection "
|
|
222
|
+
"starts with tracking how stale it has gotten."
|
|
223
|
+
),
|
|
224
|
+
signal_kind="config_audit",
|
|
225
|
+
suggested_rule=(
|
|
226
|
+
"Pull the secret's last-modified timestamp from the secret store and emit it as a gauge metric. "
|
|
227
|
+
"Page when last_rotated_age_days > 90 (or your rotation policy). Pair with token-issuance "
|
|
228
|
+
"telemetry: alert if tokens are still being signed by a key version older than the latest."
|
|
229
|
+
),
|
|
230
|
+
related_insight_ids=[insight.id],
|
|
231
|
+
attack_techniques=techniques_for_insight(insight),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _opp_for_control_bypass(insight: Insight) -> DetectionOpportunity:
|
|
236
|
+
return _opportunity(
|
|
237
|
+
opportunity_id=f"detect:{insight.id}",
|
|
238
|
+
title="Verify every request hits the global control middleware",
|
|
239
|
+
rationale=(
|
|
240
|
+
"If a control is broadly applied but specific routes bypass it, the runtime detection is "
|
|
241
|
+
"to assert that every request flows through the middleware — catching the bypass without "
|
|
242
|
+
"needing to enumerate the specific routes."
|
|
243
|
+
),
|
|
244
|
+
signal_kind="trace",
|
|
245
|
+
suggested_rule=(
|
|
246
|
+
"Tag the global middleware with an OTEL span and assert in the request-completion hook "
|
|
247
|
+
"that the request trace contains that span. Alert on missing spans in production. "
|
|
248
|
+
"Effective against intentional bypasses (webhook routes) and accidental ones (new route "
|
|
249
|
+
"added to the wrong router)."
|
|
250
|
+
),
|
|
251
|
+
related_insight_ids=[insight.id],
|
|
252
|
+
attack_techniques=techniques_for_insight(insight),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _opp_for_trust_boundary_violation(insight: Insight) -> DetectionOpportunity:
|
|
257
|
+
return _opportunity(
|
|
258
|
+
opportunity_id=f"detect:{insight.id}",
|
|
259
|
+
title="Network-level monitoring for cross-boundary traffic",
|
|
260
|
+
rationale=(
|
|
261
|
+
"An internal-only marker reachable via a public route is a structural problem; runtime "
|
|
262
|
+
"detection focuses on the network boundary itself rather than the application code."
|
|
263
|
+
),
|
|
264
|
+
signal_kind="network",
|
|
265
|
+
suggested_rule=(
|
|
266
|
+
"On the perimeter (WAF/load balancer): alert on requests whose URL or Host header maps "
|
|
267
|
+
"to internal-only routes/services according to your service inventory. Pair with a VPC flow "
|
|
268
|
+
"log rule: alert on inbound traffic to internal-classified workloads from the internet egress range."
|
|
269
|
+
),
|
|
270
|
+
related_insight_ids=[insight.id],
|
|
271
|
+
attack_techniques=techniques_for_insight(insight),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _opp_for_stale_or_contradictory_signal(insight: Insight) -> DetectionOpportunity:
|
|
276
|
+
return _opportunity(
|
|
277
|
+
opportunity_id=f"detect:{insight.id}",
|
|
278
|
+
title="Annotate routes so static analysis stops contradicting itself",
|
|
279
|
+
rationale=(
|
|
280
|
+
"Stale or contradictory signals don't usually map to a runtime detection; the leverage is "
|
|
281
|
+
"in raising the signal-to-noise of future scans."
|
|
282
|
+
),
|
|
283
|
+
signal_kind="config_audit",
|
|
284
|
+
suggested_rule=(
|
|
285
|
+
"Add a route-manifest test that asserts route categories (admin/public/internal/health) "
|
|
286
|
+
"match a single source of truth — e.g., a decorator on the handler that the test reads. "
|
|
287
|
+
"Future AttackMap runs will then trust the explicit annotation over heuristic guesses."
|
|
288
|
+
),
|
|
289
|
+
related_insight_ids=[insight.id],
|
|
290
|
+
attack_techniques=techniques_for_insight(insight),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
_INSIGHT_GENERATORS: dict[InsightKind, Callable[[Insight], DetectionOpportunity]] = {
|
|
295
|
+
"shared_secret_blast_radius": _opp_for_shared_secret_blast_radius,
|
|
296
|
+
"sensitive_asset_reachability": _opp_for_sensitive_asset_reachability,
|
|
297
|
+
"admin_action_without_auth": _opp_for_admin_action_without_auth,
|
|
298
|
+
"audit_gap": _opp_for_audit_gap,
|
|
299
|
+
"defense_gap_in_chain": _opp_for_defense_gap_in_chain,
|
|
300
|
+
"control_strength_mismatch": _opp_for_control_strength_mismatch,
|
|
301
|
+
"asymmetric_protection": _opp_for_asymmetric_protection,
|
|
302
|
+
"single_point_of_failure": _opp_for_single_point_of_failure,
|
|
303
|
+
"control_bypass": _opp_for_control_bypass,
|
|
304
|
+
"trust_boundary_violation": _opp_for_trust_boundary_violation,
|
|
305
|
+
"stale_or_contradictory_signal": _opp_for_stale_or_contradictory_signal,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def generate_detection_opportunities(
|
|
310
|
+
insights: list[Insight],
|
|
311
|
+
findings: list[Finding],
|
|
312
|
+
scan: ScanResult | None = None,
|
|
313
|
+
attack_surfaces: list[AttackSurface] | None = None,
|
|
314
|
+
) -> list[DetectionOpportunity]:
|
|
315
|
+
"""Produce one DetectionOpportunity per insight kind observed, plus
|
|
316
|
+
deployable per-finding-class rules (#251) when ``scan`` is given.
|
|
317
|
+
|
|
318
|
+
Findings are folded into existing opportunities by their rule id's
|
|
319
|
+
ATT&CK techniques so we don't double-count (e.g., a heuristic finding about webhook auth and the
|
|
320
|
+
`sensitive_asset_reachability` insight share a detection rule).
|
|
321
|
+
"""
|
|
322
|
+
opportunities: list[DetectionOpportunity] = []
|
|
323
|
+
seen_kinds: set[InsightKind] = set()
|
|
324
|
+
|
|
325
|
+
for insight in insights:
|
|
326
|
+
if insight.kind in seen_kinds:
|
|
327
|
+
continue
|
|
328
|
+
generator = _INSIGHT_GENERATORS.get(insight.kind)
|
|
329
|
+
if generator is None:
|
|
330
|
+
continue
|
|
331
|
+
seen_kinds.add(insight.kind)
|
|
332
|
+
opportunities.append(generator(insight))
|
|
333
|
+
|
|
334
|
+
if findings:
|
|
335
|
+
# Link findings by rule id (#250): a finding relates to an opportunity
|
|
336
|
+
# when its rule's registered ATT&CK techniques share a technique
|
|
337
|
+
# (parent id) with the opportunity's — ignoring T1190, which nearly
|
|
338
|
+
# every web finding carries. No title-word matching.
|
|
339
|
+
for opportunity in opportunities:
|
|
340
|
+
opp_parents = {_parent(t.technique_id) for t in opportunity.attack_techniques} - _UBIQUITOUS
|
|
341
|
+
if not opp_parents:
|
|
342
|
+
continue
|
|
343
|
+
for finding in findings:
|
|
344
|
+
entry = taxonomy_for(finding.rule_id)
|
|
345
|
+
if entry is None or not (opp_parents & {_parent(t) for t in entry.attack}):
|
|
346
|
+
continue
|
|
347
|
+
if finding.rule_id not in opportunity.related_rule_ids:
|
|
348
|
+
opportunity.related_rule_ids.append(finding.rule_id) # type: ignore[arg-type]
|
|
349
|
+
if len(opportunity.related_finding_titles) < 3:
|
|
350
|
+
opportunity.related_finding_titles.append(finding.title)
|
|
351
|
+
|
|
352
|
+
finding_techniques: list[AttackTechnique] = []
|
|
353
|
+
seen_tids: set[str] = set()
|
|
354
|
+
for finding in findings:
|
|
355
|
+
for tech in techniques_for_finding(finding):
|
|
356
|
+
if tech.technique_id in seen_tids:
|
|
357
|
+
continue
|
|
358
|
+
seen_tids.add(tech.technique_id)
|
|
359
|
+
finding_techniques.append(tech)
|
|
360
|
+
if finding_techniques and not opportunities:
|
|
361
|
+
opportunities.append(
|
|
362
|
+
_opportunity(
|
|
363
|
+
opportunity_id="detect:findings:residual",
|
|
364
|
+
title="Detection coverage for findings without a paired insight",
|
|
365
|
+
rationale=(
|
|
366
|
+
"These findings did not generate cross-cutting insights but are still "
|
|
367
|
+
"actionable with runtime telemetry."
|
|
368
|
+
),
|
|
369
|
+
signal_kind="log",
|
|
370
|
+
suggested_rule=(
|
|
371
|
+
"Add per-finding logging at the affected handler with a structured "
|
|
372
|
+
"`{finding_id, route, status_code}` payload, and aggregate in the SIEM."
|
|
373
|
+
),
|
|
374
|
+
related_finding_titles=[f.title for f in findings[:5]],
|
|
375
|
+
related_rule_ids=sorted({f.rule_id for f in findings if f.rule_id})[:10],
|
|
376
|
+
attack_techniques=finding_techniques[:6],
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
webhook = _opp_for_webhook_signatures(findings, attack_surfaces or [])
|
|
381
|
+
if webhook is not None:
|
|
382
|
+
opportunities.append(webhook)
|
|
383
|
+
|
|
384
|
+
if scan is not None:
|
|
385
|
+
opportunities.extend(_finding_class_opportunities(findings, scan))
|
|
386
|
+
|
|
387
|
+
return opportunities
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
_WEBHOOK_RULES = ("public-webhook", "webhook-weak-verification")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _opp_for_webhook_signatures(
|
|
394
|
+
findings: list[Finding], surfaces: list[AttackSurface]
|
|
395
|
+
) -> DetectionOpportunity | None:
|
|
396
|
+
"""Webhooks with absent or weak signature verification (#243, part B),
|
|
397
|
+
with a Sigma rule for deliveries that lack the signature header (#251)."""
|
|
398
|
+
related = [f for f in findings if f.rule_id in _WEBHOOK_RULES]
|
|
399
|
+
if not related:
|
|
400
|
+
return None
|
|
401
|
+
routes: list[str] = []
|
|
402
|
+
for finding in related:
|
|
403
|
+
for line in finding.evidence:
|
|
404
|
+
route = " ".join(line.split(" in ", 1)[0].split()[:2])
|
|
405
|
+
if route and route not in routes:
|
|
406
|
+
routes.append(route)
|
|
407
|
+
listed = ", ".join(f"`{r}`" for r in routes[:3]) or "the webhook routes"
|
|
408
|
+
techniques: list[AttackTechnique] = []
|
|
409
|
+
for finding in related:
|
|
410
|
+
for tech in techniques_for_finding(finding):
|
|
411
|
+
if tech.technique_id not in {t.technique_id for t in techniques}:
|
|
412
|
+
techniques.append(tech)
|
|
413
|
+
opportunity = _opportunity(
|
|
414
|
+
opportunity_id="detect:webhook-signature",
|
|
415
|
+
title="Alert on webhook requests with missing or invalid signature headers",
|
|
416
|
+
rationale=(
|
|
417
|
+
"A webhook that doesn't verify (or weakly verifies) its signature accepts forged events. "
|
|
418
|
+
"Until verification is fixed, the requests themselves show the attempt: genuine provider "
|
|
419
|
+
"deliveries always carry a valid signature and a fresh timestamp."
|
|
420
|
+
),
|
|
421
|
+
signal_kind="log",
|
|
422
|
+
suggested_rule=(
|
|
423
|
+
f"Log the signature header, its verification result and the signed timestamp for {listed}. "
|
|
424
|
+
"Alert when a request arrives with the signature header missing, failing verification, or a "
|
|
425
|
+
"timestamp older than 5 minutes; and when the same event/delivery id is seen twice. "
|
|
426
|
+
"A burst of failures from one source IP suggests forging or timing probes."
|
|
427
|
+
),
|
|
428
|
+
related_finding_titles=[f.title for f in related],
|
|
429
|
+
related_rule_ids=[f.rule_id for f in related if f.rule_id],
|
|
430
|
+
attack_techniques=techniques,
|
|
431
|
+
)
|
|
432
|
+
rendered = _webhook_rule(routes, opportunity.related_rule_ids, surfaces)
|
|
433
|
+
if rendered is not None:
|
|
434
|
+
_attach(opportunity, rendered)
|
|
435
|
+
return opportunity
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------- Deployable rules per finding class (#251) ----------
|
|
439
|
+
|
|
440
|
+
# Caps: one rule per (rule id, method, route), at most this many routes per
|
|
441
|
+
# rule id, and a ceiling on per-route rules, shared fairly across rule ids,
|
|
442
|
+
# so a huge repo can't flood the report. The two SSRF follow-ups (metadata
|
|
443
|
+
# egress, CloudTrail) are per repository and sit outside the cap. The
|
|
444
|
+
# finding itself still lists every route; the rules cover the first ones.
|
|
445
|
+
_MAX_ROUTES_PER_RULE = 5
|
|
446
|
+
_MAX_RULE_OPPORTUNITIES = 60
|
|
447
|
+
|
|
448
|
+
# Taint sink kind (canonical) -> finding rule id, where it isn't the dashed kind.
|
|
449
|
+
_FLOW_RULE_IDS = {"sql_execute": "sql-injection", "path_traversal": "path-traversal"}
|
|
450
|
+
# Sinks only flow-confirmed chains count for (#239), as in `generate_findings`.
|
|
451
|
+
_FLOW_ONLY_SINKS = frozenset({"sql_execute", "dynamic_open"})
|
|
452
|
+
_BODY_SOURCES = frozenset({"body", "header", "cookie", "upload_filename", "message"})
|
|
453
|
+
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE", "MUTATION"})
|
|
454
|
+
|
|
455
|
+
# A quoted parameter name in the traced source expression
|
|
456
|
+
# (`request.args.get("q")`, `r.URL.Query().Get("q")`, `$_GET['q']`) or a
|
|
457
|
+
# dotted one (`req.query.q`).
|
|
458
|
+
_QUOTED_PARAM = re.compile(r"""["']([A-Za-z0-9_][A-Za-z0-9_.\-]{0,63})["']""")
|
|
459
|
+
_DOTTED_PARAM = re.compile(r"\.(?:query|params|args|form|GET)\.([A-Za-z_$][A-Za-z0-9_$]{0,63})")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# `suggested_rule` prefix of opportunities whose prose is their Sigma rule.
|
|
463
|
+
SIGMA_POINTER = "Deploy Sigma rule "
|
|
464
|
+
|
|
465
|
+
_BOLA_THRESHOLD = 50
|
|
466
|
+
_BOLA_TIMESPAN = "10m"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _taint_rule_id(chain: TaintChain) -> str:
|
|
470
|
+
kind = canonical_kind(chain.sink_kind)
|
|
471
|
+
return _FLOW_RULE_IDS.get(kind, kind.replace("_", "-"))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _location_keys(finding: Finding) -> set[tuple[str, int | None]]:
|
|
475
|
+
return {(loc.file, loc.line) for loc in finding.locations}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _source_param(chain: TaintChain) -> str | None:
|
|
479
|
+
"""The query parameter carrying the tainted value, when the traced source
|
|
480
|
+
expression names one."""
|
|
481
|
+
if chain.source_kind != "query":
|
|
482
|
+
return None
|
|
483
|
+
for step in chain.flow:
|
|
484
|
+
if step.kind != "source" or not step.evidence_text:
|
|
485
|
+
continue
|
|
486
|
+
text = step.evidence_text.split(":", 1)[-1]
|
|
487
|
+
match = _QUOTED_PARAM.search(text) or _DOTTED_PARAM.search(text)
|
|
488
|
+
if match:
|
|
489
|
+
return match.group(1)
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _short(text: str, limit: int = 120) -> str:
|
|
494
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _checked(rendered: RenderedRule) -> RenderedRule | None:
|
|
498
|
+
"""Keep a rendered rule only if it is valid Sigma and its sample events
|
|
499
|
+
behave: every positive matches, no negative does. A template filled with
|
|
500
|
+
unusual repo text (a route containing a payload marker) is dropped rather
|
|
501
|
+
than shipped broken."""
|
|
502
|
+
if sigma_problems(rendered.sigma):
|
|
503
|
+
return None
|
|
504
|
+
if rendered.correlation is not None and correlation_problems(rendered.correlation, [rendered.sigma]):
|
|
505
|
+
return None
|
|
506
|
+
positives = rendered.test_events["positive"]
|
|
507
|
+
if not positives or not all(sigma_matches(rendered.sigma, e) for e in positives):
|
|
508
|
+
return None
|
|
509
|
+
if any(sigma_matches(rendered.sigma, e) for e in rendered.test_events["negative"]):
|
|
510
|
+
return None
|
|
511
|
+
return rendered
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _attach(opportunity: DetectionOpportunity, rendered: RenderedRule) -> None:
|
|
515
|
+
opportunity.sigma = rendered.sigma
|
|
516
|
+
opportunity.sigma_correlation = rendered.correlation
|
|
517
|
+
opportunity.logsource = DetectionLogsource(**rendered.logsource)
|
|
518
|
+
opportunity.fields_required = rendered.fields_required
|
|
519
|
+
opportunity.false_positive_notes = rendered.false_positive_notes
|
|
520
|
+
opportunity.test_events = DetectionTestEvents(**rendered.test_events)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _techniques(rule_id: str) -> list[AttackTechnique]:
|
|
524
|
+
entry = taxonomy_for(rule_id)
|
|
525
|
+
if entry is None:
|
|
526
|
+
return []
|
|
527
|
+
return [
|
|
528
|
+
AttackTechnique(technique_id=tid, name=name, tactic=tactic, url=attack_url(tid))
|
|
529
|
+
for tid in entry.attack
|
|
530
|
+
for name, tactic in [ATTACK_TECHNIQUES[tid]]
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _rule_opportunity(
|
|
535
|
+
*,
|
|
536
|
+
opportunity_id: str,
|
|
537
|
+
finding: Finding,
|
|
538
|
+
rendered: RenderedRule,
|
|
539
|
+
rationale: str,
|
|
540
|
+
signal_kind: str = "log",
|
|
541
|
+
) -> DetectionOpportunity:
|
|
542
|
+
sigma = rendered.sigma
|
|
543
|
+
opportunity = _opportunity(
|
|
544
|
+
opportunity_id=opportunity_id,
|
|
545
|
+
title=sigma["title"],
|
|
546
|
+
rationale=rationale,
|
|
547
|
+
signal_kind=signal_kind,
|
|
548
|
+
suggested_rule=(
|
|
549
|
+
f"{SIGMA_POINTER}{sigma['id']} ({DetectionLogsource(**rendered.logsource).label()} logs). "
|
|
550
|
+
+ sigma["description"]
|
|
551
|
+
),
|
|
552
|
+
related_finding_titles=[finding.title],
|
|
553
|
+
related_rule_ids=[finding.rule_id] if finding.rule_id else [],
|
|
554
|
+
attack_techniques=_techniques(finding.rule_id or ""),
|
|
555
|
+
)
|
|
556
|
+
_attach(opportunity, rendered)
|
|
557
|
+
return opportunity
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _sigma_escape(text: str) -> str:
|
|
561
|
+
"""Escape Sigma's wildcard characters so a marker matches literally."""
|
|
562
|
+
return text.replace("\\", "\\\\").replace("*", "\\*").replace("?", "\\?")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _regex_escape(text: str) -> str:
|
|
566
|
+
return regex_literal(text)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _payload_selector(
|
|
570
|
+
payload: dict, param: str | None, source_kind: str | None, path_has_params: bool
|
|
571
|
+
) -> tuple[dict | list[dict], str, bool]:
|
|
572
|
+
"""The payload selection (a field map, or a list of maps = any of them),
|
|
573
|
+
where it looks (for the description), and whether the positive sample
|
|
574
|
+
belongs in the request path rather than the query string."""
|
|
575
|
+
raw = [str(m) for m in payload["contains"]]
|
|
576
|
+
markers = [_sigma_escape(m) for m in raw]
|
|
577
|
+
prefix = payload.get("anchor") == "prefix"
|
|
578
|
+
if source_kind == "path_param" and path_has_params:
|
|
579
|
+
return {"cs-uri-stem|contains": markers}, "path", True
|
|
580
|
+
if param and payload.get("regex", True) and source_kind in (None, "query"):
|
|
581
|
+
alternatives = "|".join(_regex_escape(m) for m in raw)
|
|
582
|
+
regex = f"(?:^|&){_regex_escape(param)}=" + ("" if prefix else "[^&]*") + f"(?:{alternatives})"
|
|
583
|
+
return {"cs-uri-query|re|i": regex}, f"`{param}` query parameter", False
|
|
584
|
+
query = {"cs-uri-query|contains": [f"={m}" for m in markers] if prefix else markers}
|
|
585
|
+
if source_kind is None and path_has_params:
|
|
586
|
+
# Reachability-only chain: the input may be the path parameter or a
|
|
587
|
+
# query parameter, so look in both.
|
|
588
|
+
return [query, {"cs-uri-stem|contains": markers}], "query string or path", False
|
|
589
|
+
return query, "query string", False
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _route_params(path: str) -> dict[str, str] | None:
|
|
593
|
+
"""Route selection params, or ``None`` when the route is too broad or
|
|
594
|
+
too long to scope a rule to (see `detections.route_scopable`)."""
|
|
595
|
+
selector = route_selector(path)
|
|
596
|
+
if selector is None:
|
|
597
|
+
return None
|
|
598
|
+
return {"route_field": selector[0], "route_value": selector[1]}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _taint_rule(rule_id: str, chain: TaintChain, payload: dict) -> RenderedRule | None:
|
|
602
|
+
route = _route_params(chain.route_path)
|
|
603
|
+
if route is None:
|
|
604
|
+
return None
|
|
605
|
+
method = sigma_method(chain.route_method)
|
|
606
|
+
method_route = _short(f"{chain.route_method} {chain.route_path}")
|
|
607
|
+
param = _source_param(chain)
|
|
608
|
+
in_path = has_path_params(chain.route_path)
|
|
609
|
+
selection, where, in_stem = _payload_selector(payload, param, chain.source_kind, in_path)
|
|
610
|
+
name = param or "q"
|
|
611
|
+
sample, benign = str(payload["sample"]), str(payload["benign"])
|
|
612
|
+
if in_stem:
|
|
613
|
+
positive_stem, positive_query = route_example(chain.route_path, sample), ""
|
|
614
|
+
else:
|
|
615
|
+
positive_stem = route_example(chain.route_path)
|
|
616
|
+
positive_query = str(payload.get("query", "${param}=${sample}")).replace("${param}", name).replace(
|
|
617
|
+
"${sample}", sample
|
|
618
|
+
)
|
|
619
|
+
blind = ""
|
|
620
|
+
if chain.source_kind in _BODY_SOURCES:
|
|
621
|
+
blind = (
|
|
622
|
+
f" Blind spot: the traced input arrives in the request {chain.source_kind}, which access logs "
|
|
623
|
+
"don't record; feed request bodies from a WAF or application log into cs-uri-query, or adapt the field."
|
|
624
|
+
)
|
|
625
|
+
source = f"{chain.source_kind} parameter {param}" if param else (chain.source_kind or "a request value")
|
|
626
|
+
rendered = render_rule(
|
|
627
|
+
"injection-route",
|
|
628
|
+
rule_ids=[rule_id],
|
|
629
|
+
key=f"{chain.route_method} {chain.route_path} {chain.route_file}",
|
|
630
|
+
params={
|
|
631
|
+
"label": payload["label"],
|
|
632
|
+
"level": payload["level"],
|
|
633
|
+
"method_route": method_route,
|
|
634
|
+
"method": method,
|
|
635
|
+
"method_example": method or "GET",
|
|
636
|
+
**route,
|
|
637
|
+
"route_example": route_example(chain.route_path),
|
|
638
|
+
"payload_selection": selection,
|
|
639
|
+
"payload_where": where,
|
|
640
|
+
"positive_stem": positive_stem,
|
|
641
|
+
"positive_query": positive_query,
|
|
642
|
+
"negative_query": f"{name}={benign}",
|
|
643
|
+
"source": source,
|
|
644
|
+
"location": f"{chain.sink_file}:{chain.sink_line}" if chain.sink_line else chain.sink_file,
|
|
645
|
+
"rule_id": rule_id,
|
|
646
|
+
"blind_spot": blind,
|
|
647
|
+
},
|
|
648
|
+
extra_falsepositives=list(payload.get("falsepositives") or []),
|
|
649
|
+
)
|
|
650
|
+
return _checked(rendered)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _taint_opportunities(scan: ScanResult, by_rule: dict[str, Finding]) -> list[DetectionOpportunity]:
|
|
654
|
+
payloads = template_data("injection").get("payloads", {})
|
|
655
|
+
covered = {rule_id: _location_keys(finding) for rule_id, finding in by_rule.items()}
|
|
656
|
+
out: list[DetectionOpportunity] = []
|
|
657
|
+
seen: set[tuple[str, str, str]] = set()
|
|
658
|
+
per_rule: dict[str, int] = {}
|
|
659
|
+
for chain in scan.taint_chains:
|
|
660
|
+
if chain.sanitized or chain.speculative:
|
|
661
|
+
continue
|
|
662
|
+
if chain.sink_kind in _FLOW_ONLY_SINKS and not chain.source_kind:
|
|
663
|
+
continue
|
|
664
|
+
rule_id = _taint_rule_id(chain)
|
|
665
|
+
finding = by_rule.get(rule_id)
|
|
666
|
+
payload = payloads.get(rule_id)
|
|
667
|
+
if finding is None or payload is None:
|
|
668
|
+
continue
|
|
669
|
+
# The chain must be one the (possibly partially suppressed) finding covers.
|
|
670
|
+
if (chain.sink_file, chain.sink_line) not in covered[rule_id]:
|
|
671
|
+
continue
|
|
672
|
+
key = (rule_id, chain.route_method.upper(), chain.route_path)
|
|
673
|
+
if key in seen or per_rule.get(rule_id, 0) >= _MAX_ROUTES_PER_RULE:
|
|
674
|
+
continue
|
|
675
|
+
rendered = _taint_rule(rule_id, chain, payload)
|
|
676
|
+
if rendered is None:
|
|
677
|
+
continue
|
|
678
|
+
seen.add(key)
|
|
679
|
+
per_rule[rule_id] = per_rule.get(rule_id, 0) + 1
|
|
680
|
+
out.append(
|
|
681
|
+
_rule_opportunity(
|
|
682
|
+
opportunity_id=f"detect:{rule_id}:{chain.route_method.upper()}:{chain.route_path}",
|
|
683
|
+
finding=finding,
|
|
684
|
+
rendered=rendered,
|
|
685
|
+
rationale=(
|
|
686
|
+
f"Request input reaches a {payload['label'].lower()} sink from "
|
|
687
|
+
f"{_short(chain.route_method + ' ' + chain.route_path)}. Requests carrying "
|
|
688
|
+
f"{payload['label'].lower()} syntax to that route are the exploitation attempts."
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
return out
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _ssrf_followups(
|
|
696
|
+
ssrf_opps: list[DetectionOpportunity], finding: Finding, scan: ScanResult
|
|
697
|
+
) -> list[DetectionOpportunity]:
|
|
698
|
+
"""Metadata-service egress and instance-credential reuse, once per scan."""
|
|
699
|
+
covered = _location_keys(finding)
|
|
700
|
+
chain = next(
|
|
701
|
+
(
|
|
702
|
+
c
|
|
703
|
+
for c in scan.taint_chains
|
|
704
|
+
if _taint_rule_id(c) == "ssrf" and not c.sanitized and not c.speculative
|
|
705
|
+
and (c.sink_file, c.sink_line) in covered
|
|
706
|
+
),
|
|
707
|
+
None,
|
|
708
|
+
)
|
|
709
|
+
if chain is None or not ssrf_opps:
|
|
710
|
+
return []
|
|
711
|
+
location = f"{chain.sink_file}:{chain.sink_line}" if chain.sink_line else chain.sink_file
|
|
712
|
+
out: list[DetectionOpportunity] = []
|
|
713
|
+
for template_id, signal_kind, rationale in (
|
|
714
|
+
(
|
|
715
|
+
"ssrf-metadata-egress",
|
|
716
|
+
"network",
|
|
717
|
+
"A working SSRF is usually pointed at the cloud metadata service first; the connection itself is "
|
|
718
|
+
"visible in host or VPC network telemetry regardless of which route carried the request.",
|
|
719
|
+
),
|
|
720
|
+
(
|
|
721
|
+
"ssrf-instance-credentials-cloudtrail",
|
|
722
|
+
"log",
|
|
723
|
+
"Credentials stolen from the metadata service through SSRF are then used from the attacker's "
|
|
724
|
+
"infrastructure; CloudTrail shows the instance role's session calling AWS from a foreign IP.",
|
|
725
|
+
),
|
|
726
|
+
):
|
|
727
|
+
rendered = _checked(
|
|
728
|
+
render_rule(template_id, rule_ids=["ssrf"], key=chain.sink_file, params={"location": location})
|
|
729
|
+
)
|
|
730
|
+
if rendered is not None:
|
|
731
|
+
out.append(
|
|
732
|
+
_rule_opportunity(
|
|
733
|
+
opportunity_id=f"detect:ssrf:{template_id}",
|
|
734
|
+
finding=finding,
|
|
735
|
+
rendered=rendered,
|
|
736
|
+
rationale=rationale,
|
|
737
|
+
signal_kind=signal_kind,
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
return out
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _bola_rule(rule_id: str, cand: BolaCandidate) -> RenderedRule | None:
|
|
744
|
+
route = _route_params(cand.route_path)
|
|
745
|
+
if route is None:
|
|
746
|
+
return None
|
|
747
|
+
method = sigma_method(cand.route_method)
|
|
748
|
+
in_path = cand.surface == "path_param" and has_path_params(cand.route_path)
|
|
749
|
+
value_field = "cs-uri-stem" if in_path else "cs-uri-query"
|
|
750
|
+
principal = "cs-username" if cand.authn_evidence else "c-ip"
|
|
751
|
+
key = f"{cand.route_method} {cand.route_path}"
|
|
752
|
+
rendered = render_rule(
|
|
753
|
+
"bola-cardinality",
|
|
754
|
+
rule_ids=[rule_id],
|
|
755
|
+
key=f"{key} {cand.route_file}",
|
|
756
|
+
params={
|
|
757
|
+
"rule_name": "attackmap_bola_"
|
|
758
|
+
+ rule_uuid("bola-cardinality", rule_id, f"{key} {cand.route_file}").replace("-", "")[:12],
|
|
759
|
+
"method_route": _short(key),
|
|
760
|
+
"method": method,
|
|
761
|
+
"method_example": method or "GET",
|
|
762
|
+
**route,
|
|
763
|
+
"positive_stem": route_example(cand.route_path),
|
|
764
|
+
"positive_query": "" if in_path else f"{cand.id_param}=1001",
|
|
765
|
+
"id_param": cand.id_param,
|
|
766
|
+
"rule_id": rule_id,
|
|
767
|
+
"location": f"{cand.route_file}:{cand.route_line}" if cand.route_line else cand.route_file,
|
|
768
|
+
"principal_field": principal,
|
|
769
|
+
"principal_value": "alice" if principal == "cs-username" else "203.0.113.10",
|
|
770
|
+
"value_field": value_field,
|
|
771
|
+
"threshold": _BOLA_THRESHOLD,
|
|
772
|
+
"timespan": _BOLA_TIMESPAN,
|
|
773
|
+
"level": "high" if rule_id == "bola-modify" else "medium",
|
|
774
|
+
},
|
|
775
|
+
)
|
|
776
|
+
return _checked(rendered)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _bola_opportunities(scan: ScanResult, by_rule: dict[str, Finding]) -> list[DetectionOpportunity]:
|
|
780
|
+
covered = {r: _location_keys(f) for r, f in by_rule.items() if r in ("bola-read", "bola-modify")}
|
|
781
|
+
out: list[DetectionOpportunity] = []
|
|
782
|
+
seen: set[tuple[str, str, str]] = set()
|
|
783
|
+
per_rule: dict[str, int] = {}
|
|
784
|
+
for cand in scan.authz_candidates:
|
|
785
|
+
if cand.has_ownership_check or cand.surface == "graphql_field":
|
|
786
|
+
continue
|
|
787
|
+
rule_id = "bola-modify" if cand.route_method.upper() in _WRITE_METHODS else "bola-read"
|
|
788
|
+
finding = by_rule.get(rule_id)
|
|
789
|
+
if finding is None or (cand.route_file, cand.route_line) not in covered[rule_id]:
|
|
790
|
+
continue
|
|
791
|
+
key = (rule_id, cand.route_method.upper(), cand.route_path)
|
|
792
|
+
if key in seen or per_rule.get(rule_id, 0) >= _MAX_ROUTES_PER_RULE:
|
|
793
|
+
continue
|
|
794
|
+
rendered = _bola_rule(rule_id, cand)
|
|
795
|
+
if rendered is None:
|
|
796
|
+
continue
|
|
797
|
+
seen.add(key)
|
|
798
|
+
per_rule[rule_id] = per_rule.get(rule_id, 0) + 1
|
|
799
|
+
out.append(
|
|
800
|
+
_rule_opportunity(
|
|
801
|
+
opportunity_id=f"detect:{rule_id}:{cand.route_method.upper()}:{cand.route_path}",
|
|
802
|
+
finding=finding,
|
|
803
|
+
rendered=rendered,
|
|
804
|
+
rationale=(
|
|
805
|
+
f"{_short(cand.route_method + ' ' + cand.route_path)} takes the object id `{cand.id_param}` "
|
|
806
|
+
"without an object-level check. Enumeration shows up as one principal touching far more "
|
|
807
|
+
"distinct objects than normal use does."
|
|
808
|
+
),
|
|
809
|
+
)
|
|
810
|
+
)
|
|
811
|
+
return out
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _webhook_rule(
|
|
815
|
+
routes: list[str], rule_ids: list[str], surfaces: list[AttackSurface]
|
|
816
|
+
) -> RenderedRule | None:
|
|
817
|
+
"""Sigma rule for POSTs to the webhook routes without the provider's
|
|
818
|
+
signature header. Routes whose provider is unknown are checked against
|
|
819
|
+
every known signature header."""
|
|
820
|
+
headers: dict[str, list[str]] = template_data("webhook").get("headers", {})
|
|
821
|
+
providers: dict[str, str] = {}
|
|
822
|
+
for surface in surfaces:
|
|
823
|
+
verification = surface.webhook_verification
|
|
824
|
+
if verification is not None and verification.provider in headers:
|
|
825
|
+
providers.setdefault(surface.route, verification.provider)
|
|
826
|
+
# Sorted, so the rule (and its id) doesn't depend on evidence order;
|
|
827
|
+
# catch-all routes (`/:hook`, `*`) are left out rather than widening the
|
|
828
|
+
# rule to every POST on the site.
|
|
829
|
+
scoped = sorted({r.split(" ", 1)[-1] for r in routes if route_scopable(r.split(" ", 1)[-1])})[:10]
|
|
830
|
+
groups: dict[str, list[str]] = {}
|
|
831
|
+
for path in scoped:
|
|
832
|
+
groups.setdefault(providers.get(path, "unknown"), []).append(path)
|
|
833
|
+
if not groups:
|
|
834
|
+
return None
|
|
835
|
+
all_headers = [h for hs in headers.values() for h in hs]
|
|
836
|
+
detection: dict[str, object] = {}
|
|
837
|
+
used: list[str] = []
|
|
838
|
+
for provider, paths in sorted(groups.items()):
|
|
839
|
+
names = headers.get(provider, all_headers)
|
|
840
|
+
stem = stem_selector(paths)
|
|
841
|
+
if stem is None:
|
|
842
|
+
continue
|
|
843
|
+
selection: dict[str, object] = {"cs-method": "POST", stem[0]: stem[1]}
|
|
844
|
+
for header in names:
|
|
845
|
+
selection[f"cs({header})"] = None
|
|
846
|
+
if header not in used:
|
|
847
|
+
used.append(header)
|
|
848
|
+
detection[f"selection_{provider}"] = selection
|
|
849
|
+
detection["condition"] = "1 of selection_*"
|
|
850
|
+
first_provider, first_paths = next(iter(sorted(groups.items())))
|
|
851
|
+
example = route_example(first_paths[0])
|
|
852
|
+
signed = headers.get(first_provider, all_headers)
|
|
853
|
+
known = sorted(p for p in groups if p != "unknown")
|
|
854
|
+
paths = scoped
|
|
855
|
+
rendered = render_rule(
|
|
856
|
+
"webhook-signature-missing",
|
|
857
|
+
rule_ids=rule_ids or ["public-webhook"],
|
|
858
|
+
key="|".join(scoped),
|
|
859
|
+
params={
|
|
860
|
+
"routes_text": ", ".join(_short(p, 80) for p in paths[:3]) + (" and more" if len(paths) > 3 else ""),
|
|
861
|
+
"provider_text": "/".join(known) if known else "known provider",
|
|
862
|
+
"state_text": "absent or weak signature verification",
|
|
863
|
+
"rule_ids_text": ", ".join(rule_ids) or "public-webhook",
|
|
864
|
+
"fields": ["cs-method", "cs-uri-stem", "sc-status", *[f"cs({h})" for h in used]],
|
|
865
|
+
"detection": detection,
|
|
866
|
+
"route_example": example,
|
|
867
|
+
"signed_event": {
|
|
868
|
+
"cs-method": "POST",
|
|
869
|
+
"cs-uri-stem": example,
|
|
870
|
+
"sc-status": 200,
|
|
871
|
+
**{f"cs({h})": "t=1700000000,v1=5257a869e7" for h in signed},
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
)
|
|
875
|
+
return _checked(rendered)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _workflow_opportunities(scan: ScanResult, by_rule: dict[str, Finding]) -> list[DetectionOpportunity]:
|
|
879
|
+
"""GitHub audit-log rules per untrusted trigger, and the CloudTrail rule
|
|
880
|
+
for OIDC tokens minted by pull-request runs."""
|
|
881
|
+
triggers: dict[str, dict] = template_data("workflow").get("triggers", {})
|
|
882
|
+
# rule id -> workflow files its (unsuppressed) finding covers
|
|
883
|
+
files: dict[str, list[str]] = {}
|
|
884
|
+
covered: dict[str, set[tuple[str, int | None]]] = {}
|
|
885
|
+
for issue in scan.workflow_issues:
|
|
886
|
+
rule_id = issue.kind.replace("_", "-")
|
|
887
|
+
finding = by_rule.get(rule_id)
|
|
888
|
+
if finding is None:
|
|
889
|
+
continue
|
|
890
|
+
if rule_id not in covered:
|
|
891
|
+
covered[rule_id] = _location_keys(finding)
|
|
892
|
+
if (issue.file, issue.line) not in covered[rule_id]:
|
|
893
|
+
continue
|
|
894
|
+
if issue.file not in files.setdefault(rule_id, []):
|
|
895
|
+
files[rule_id].append(issue.file)
|
|
896
|
+
out: list[DetectionOpportunity] = []
|
|
897
|
+
# One audit-log rule per trigger set; rule ids sharing it share the rule.
|
|
898
|
+
by_events: dict[tuple[str, ...], list[str]] = {}
|
|
899
|
+
for rule_id in files:
|
|
900
|
+
if rule_id in triggers:
|
|
901
|
+
by_events.setdefault(tuple(triggers[rule_id]["events"]), []).append(rule_id)
|
|
902
|
+
for events, rule_ids in by_events.items():
|
|
903
|
+
rule_ids = sorted(rule_ids)
|
|
904
|
+
locations = [f for r in rule_ids for f in files[r]]
|
|
905
|
+
rendered = _checked(
|
|
906
|
+
render_rule(
|
|
907
|
+
"github-untrusted-trigger-run",
|
|
908
|
+
rule_ids=rule_ids,
|
|
909
|
+
key="|".join([_repo_key(scan), *events, *sorted(locations)]),
|
|
910
|
+
params={
|
|
911
|
+
"trigger_text": "/".join(events),
|
|
912
|
+
"triggers": list(events),
|
|
913
|
+
"trigger_example": events[0],
|
|
914
|
+
"kind_label": "; ".join(triggers[r]["label"] for r in rule_ids),
|
|
915
|
+
"location": _listed(locations),
|
|
916
|
+
"rule_id": ", ".join(rule_ids),
|
|
917
|
+
},
|
|
918
|
+
)
|
|
919
|
+
)
|
|
920
|
+
if rendered is None:
|
|
921
|
+
continue
|
|
922
|
+
opportunity = _rule_opportunity(
|
|
923
|
+
opportunity_id=f"detect:workflow:{'+'.join(events)}",
|
|
924
|
+
finding=by_rule[rule_ids[0]],
|
|
925
|
+
rendered=rendered,
|
|
926
|
+
rationale=(
|
|
927
|
+
f"Runs triggered by {'/'.join(events)} execute attacker-influenced code in a workflow AttackMap "
|
|
928
|
+
"flagged; the audit log shows who started each run."
|
|
929
|
+
),
|
|
930
|
+
)
|
|
931
|
+
for rule_id in rule_ids[1:]:
|
|
932
|
+
opportunity.related_rule_ids.append(rule_id)
|
|
933
|
+
opportunity.related_finding_titles.append(by_rule[rule_id].title)
|
|
934
|
+
out.append(opportunity)
|
|
935
|
+
if "oidc-on-untrusted-trigger" in files:
|
|
936
|
+
rendered = _checked(
|
|
937
|
+
render_rule(
|
|
938
|
+
"github-oidc-untrusted-trigger-cloudtrail",
|
|
939
|
+
rule_ids=["oidc-on-untrusted-trigger"],
|
|
940
|
+
key="|".join([_repo_key(scan), *sorted(files["oidc-on-untrusted-trigger"])]),
|
|
941
|
+
params={
|
|
942
|
+
"rule_id": "oidc-on-untrusted-trigger",
|
|
943
|
+
"location": _listed(files["oidc-on-untrusted-trigger"]),
|
|
944
|
+
},
|
|
945
|
+
)
|
|
946
|
+
)
|
|
947
|
+
if rendered is not None:
|
|
948
|
+
out.append(
|
|
949
|
+
_rule_opportunity(
|
|
950
|
+
opportunity_id="detect:oidc-on-untrusted-trigger:cloudtrail",
|
|
951
|
+
finding=by_rule["oidc-on-untrusted-trigger"],
|
|
952
|
+
rendered=rendered,
|
|
953
|
+
rationale=(
|
|
954
|
+
"An OIDC token minted by a pull-request run is only dangerous where a cloud role trusts it; "
|
|
955
|
+
"CloudTrail shows each such role assumption."
|
|
956
|
+
),
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
return out
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _secret_opportunities(scan: ScanResult, by_rule: dict[str, Finding]) -> list[DetectionOpportunity]:
|
|
963
|
+
"""CloudTrail rule for committed AWS access keys (placeholder key id:
|
|
964
|
+
AttackMap keeps no secret values)."""
|
|
965
|
+
data = template_data("secrets")
|
|
966
|
+
kinds = set(data.get("aws_key_kinds") or [])
|
|
967
|
+
out: list[DetectionOpportunity] = []
|
|
968
|
+
for rule_id, items in (
|
|
969
|
+
("hardcoded-secret", scan.secret_hints),
|
|
970
|
+
("secret-in-git-history", scan.secret_history),
|
|
971
|
+
):
|
|
972
|
+
finding = by_rule.get(rule_id)
|
|
973
|
+
if finding is None:
|
|
974
|
+
continue
|
|
975
|
+
covered = _location_keys(finding)
|
|
976
|
+
hits = [h for h in items if h.kind in kinds and (h.file, h.line) in covered]
|
|
977
|
+
if not hits:
|
|
978
|
+
continue
|
|
979
|
+
locations = [f"{h.file}:{h.line}" if h.line else h.file for h in hits]
|
|
980
|
+
rendered = _checked(
|
|
981
|
+
render_rule(
|
|
982
|
+
"aws-leaked-access-key-cloudtrail",
|
|
983
|
+
rule_ids=[rule_id],
|
|
984
|
+
key="|".join([_repo_key(scan), *sorted(locations)]),
|
|
985
|
+
params={
|
|
986
|
+
"rule_id": rule_id,
|
|
987
|
+
"locations": _listed(locations),
|
|
988
|
+
"placeholder": data.get("placeholder", "REPLACE_WITH_LEAKED_ACCESS_KEY_ID"),
|
|
989
|
+
},
|
|
990
|
+
)
|
|
991
|
+
)
|
|
992
|
+
if rendered is not None:
|
|
993
|
+
out.append(
|
|
994
|
+
_rule_opportunity(
|
|
995
|
+
opportunity_id=f"detect:{rule_id}:aws-access-key",
|
|
996
|
+
finding=finding,
|
|
997
|
+
rendered=rendered,
|
|
998
|
+
rationale=(
|
|
999
|
+
"A committed AWS key must be rotated, but the leaked key itself stays useful as a tripwire: "
|
|
1000
|
+
"kept active with no permissions, any use of it is an attacker who copied it."
|
|
1001
|
+
),
|
|
1002
|
+
)
|
|
1003
|
+
)
|
|
1004
|
+
return out
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _weakness_opportunities(scan: ScanResult, by_rule: dict[str, Finding]) -> list[DetectionOpportunity]:
|
|
1008
|
+
"""App-wide request-marker rules for code weaknesses without a route."""
|
|
1009
|
+
markers: dict[str, dict] = template_data("weakness").get("markers", {})
|
|
1010
|
+
first: dict[str, str] = {}
|
|
1011
|
+
covered: dict[str, set[tuple[str, int | None]]] = {}
|
|
1012
|
+
for weakness in scan.code_weaknesses:
|
|
1013
|
+
rule_id = weakness.kind.replace("_", "-")
|
|
1014
|
+
finding = by_rule.get(rule_id)
|
|
1015
|
+
if rule_id in first or rule_id not in markers or finding is None:
|
|
1016
|
+
continue
|
|
1017
|
+
suffixes = tuple(markers[rule_id].get("suffixes") or ())
|
|
1018
|
+
if suffixes and not weakness.file.lower().endswith(suffixes):
|
|
1019
|
+
continue
|
|
1020
|
+
if rule_id not in covered:
|
|
1021
|
+
covered[rule_id] = _location_keys(finding)
|
|
1022
|
+
if (weakness.file, weakness.line) not in covered[rule_id]:
|
|
1023
|
+
continue
|
|
1024
|
+
first[rule_id] = f"{weakness.file}:{weakness.line}" if weakness.line else weakness.file
|
|
1025
|
+
out: list[DetectionOpportunity] = []
|
|
1026
|
+
for rule_id, location in first.items():
|
|
1027
|
+
spec = markers[rule_id]
|
|
1028
|
+
rendered = _checked(
|
|
1029
|
+
render_rule(
|
|
1030
|
+
"weakness-request-markers",
|
|
1031
|
+
rule_ids=[rule_id],
|
|
1032
|
+
key=f"{_repo_key(scan)}|{location.rsplit(':', 1)[0]}",
|
|
1033
|
+
params={
|
|
1034
|
+
"label": spec["label"],
|
|
1035
|
+
"kind_label": spec["kind_label"],
|
|
1036
|
+
"level": spec["level"],
|
|
1037
|
+
"markers": [_sigma_escape(str(m)) for m in spec["contains"]],
|
|
1038
|
+
"falsepositives": list(spec.get("falsepositives") or []),
|
|
1039
|
+
"sample": spec["sample"],
|
|
1040
|
+
"benign": spec["benign"],
|
|
1041
|
+
"location": location,
|
|
1042
|
+
"rule_id": rule_id,
|
|
1043
|
+
},
|
|
1044
|
+
)
|
|
1045
|
+
)
|
|
1046
|
+
if rendered is not None:
|
|
1047
|
+
out.append(
|
|
1048
|
+
_rule_opportunity(
|
|
1049
|
+
opportunity_id=f"detect:{rule_id}:requests",
|
|
1050
|
+
finding=by_rule[rule_id],
|
|
1051
|
+
rendered=rendered,
|
|
1052
|
+
rationale=(
|
|
1053
|
+
f"{spec['label']} has no single route here; requests probing for it carry recognizable "
|
|
1054
|
+
"markers in the query string."
|
|
1055
|
+
),
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
return out
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _repo_key(scan: ScanResult) -> str:
|
|
1062
|
+
"""The scanned directory's name, folded into the ids of repository-wide
|
|
1063
|
+
rules (workflows, keys, weaknesses) whose other inputs, such as
|
|
1064
|
+
`.github/workflows/ci.yml`, repeat across repositories."""
|
|
1065
|
+
try:
|
|
1066
|
+
return Path(scan.root).resolve().name
|
|
1067
|
+
except (OSError, RuntimeError, ValueError):
|
|
1068
|
+
return Path(scan.root).name
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _listed(items: list[str], limit: int = 3) -> str:
|
|
1072
|
+
shown = ", ".join(_short(i, 80) for i in items[:limit])
|
|
1073
|
+
return shown + (f" and {len(items) - limit} more" if len(items) > limit else "")
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _cloud_opportunities(scan: ScanResult, findings: list[Finding]) -> list[DetectionOpportunity]:
|
|
1077
|
+
"""Kubernetes audit-log and CloudTrail rules for cloud / IaC
|
|
1078
|
+
misconfigurations (#351), one per template per repository. A template
|
|
1079
|
+
applies to the records its mapping allows (platform, AWS resource type)
|
|
1080
|
+
that a finding of that rule id still covers after suppression; cloud
|
|
1081
|
+
findings are tiered, so the covered set is the union over the tiers."""
|
|
1082
|
+
mapping: dict[str, list[dict]] = template_data("cloud").get("mapping", {})
|
|
1083
|
+
if not scan.cloud_misconfigs or not mapping:
|
|
1084
|
+
return []
|
|
1085
|
+
covered: dict[str, set[tuple[str, int | None]]] = {}
|
|
1086
|
+
first: dict[str, Finding] = {}
|
|
1087
|
+
for finding in findings:
|
|
1088
|
+
if finding.rule_id in mapping:
|
|
1089
|
+
covered.setdefault(finding.rule_id, set()).update(_location_keys(finding))
|
|
1090
|
+
first.setdefault(finding.rule_id, finding)
|
|
1091
|
+
# template id -> (rule ids, locations), in first-seen order
|
|
1092
|
+
plan: dict[str, tuple[list[str], list[str]]] = {}
|
|
1093
|
+
for record in scan.cloud_misconfigs:
|
|
1094
|
+
rule_id = record.kind.replace("_", "-")
|
|
1095
|
+
if rule_id not in covered or (record.file, record.line) not in covered[rule_id]:
|
|
1096
|
+
continue
|
|
1097
|
+
resource_type = (record.resource or "").split(".", 1)[0]
|
|
1098
|
+
for spec in mapping[rule_id]:
|
|
1099
|
+
platforms = spec.get("platforms") or []
|
|
1100
|
+
types = spec.get("resource_types") or []
|
|
1101
|
+
if platforms and record.platform not in platforms:
|
|
1102
|
+
continue
|
|
1103
|
+
if types and resource_type not in types:
|
|
1104
|
+
continue
|
|
1105
|
+
rule_ids, locations = plan.setdefault(spec["template"], ([], []))
|
|
1106
|
+
if rule_id not in rule_ids:
|
|
1107
|
+
rule_ids.append(rule_id)
|
|
1108
|
+
where = f"{record.file}:{record.line}" if record.line else record.file
|
|
1109
|
+
if record.resource:
|
|
1110
|
+
where = f"{record.resource} ({where})"
|
|
1111
|
+
if where not in locations:
|
|
1112
|
+
locations.append(where)
|
|
1113
|
+
out: list[DetectionOpportunity] = []
|
|
1114
|
+
# Sorted, so neither the rule text nor the id depends on record order.
|
|
1115
|
+
for template_id, (rule_ids, locations) in sorted(plan.items()):
|
|
1116
|
+
rule_ids = sorted(rule_ids)
|
|
1117
|
+
locations = sorted(locations)
|
|
1118
|
+
rendered = _checked(
|
|
1119
|
+
render_rule(
|
|
1120
|
+
template_id,
|
|
1121
|
+
rule_ids=rule_ids,
|
|
1122
|
+
key="|".join([_repo_key(scan), template_id]),
|
|
1123
|
+
params={"locations": _listed(locations), "rule_id": ", ".join(rule_ids)},
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
if rendered is None:
|
|
1127
|
+
continue
|
|
1128
|
+
opportunity = _rule_opportunity(
|
|
1129
|
+
opportunity_id=f"detect:cloud:{template_id}",
|
|
1130
|
+
finding=first[rule_ids[0]],
|
|
1131
|
+
rendered=rendered,
|
|
1132
|
+
rationale=(
|
|
1133
|
+
"The repository's infrastructure code applies this setting; the control-plane log shows "
|
|
1134
|
+
"it being applied, by whom, and to which resource, including copies AttackMap never saw."
|
|
1135
|
+
),
|
|
1136
|
+
)
|
|
1137
|
+
for rule_id in rule_ids[1:]:
|
|
1138
|
+
opportunity.related_rule_ids.append(rule_id)
|
|
1139
|
+
opportunity.related_finding_titles.append(first[rule_id].title)
|
|
1140
|
+
out.append(opportunity)
|
|
1141
|
+
return out
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _finding_class_opportunities(findings: list[Finding], scan: ScanResult) -> list[DetectionOpportunity]:
|
|
1145
|
+
by_rule: dict[str, Finding] = {}
|
|
1146
|
+
for finding in findings:
|
|
1147
|
+
if finding.rule_id and finding.rule_id not in by_rule:
|
|
1148
|
+
by_rule[finding.rule_id] = finding
|
|
1149
|
+
# Per-route rules share one budget (fairly across rule ids); the
|
|
1150
|
+
# per-repository rules (a handful at most, one per template) are always kept.
|
|
1151
|
+
routes = _fair_cap(_taint_opportunities(scan, by_rule) + _bola_opportunities(scan, by_rule))
|
|
1152
|
+
out = [o for o in routes if not o.id.startswith("detect:bola-")]
|
|
1153
|
+
ssrf_finding = by_rule.get("ssrf")
|
|
1154
|
+
if ssrf_finding is not None:
|
|
1155
|
+
out += _ssrf_followups([o for o in out if "ssrf" in o.related_rule_ids], ssrf_finding, scan)
|
|
1156
|
+
out += [o for o in routes if o.id.startswith("detect:bola-")]
|
|
1157
|
+
out += _workflow_opportunities(scan, by_rule)
|
|
1158
|
+
out += _secret_opportunities(scan, by_rule)
|
|
1159
|
+
out += _weakness_opportunities(scan, by_rule)
|
|
1160
|
+
out += _cloud_opportunities(scan, findings)
|
|
1161
|
+
return out
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _fair_cap(opportunities: list[DetectionOpportunity], limit: int = _MAX_RULE_OPPORTUNITIES) -> list[DetectionOpportunity]:
|
|
1165
|
+
"""At most ``limit`` per-route rules, taken round-robin across rule ids
|
|
1166
|
+
so no class (often BOLA, generated last) is the one always dropped.
|
|
1167
|
+
Kept rules stay in their original order."""
|
|
1168
|
+
if len(opportunities) <= limit:
|
|
1169
|
+
return opportunities
|
|
1170
|
+
by_rule: dict[str, list[int]] = {}
|
|
1171
|
+
for index, opp in enumerate(opportunities):
|
|
1172
|
+
by_rule.setdefault(opp.related_rule_ids[0] if opp.related_rule_ids else "", []).append(index)
|
|
1173
|
+
keep: set[int] = set()
|
|
1174
|
+
queues = list(by_rule.values())
|
|
1175
|
+
depth = 0
|
|
1176
|
+
while len(keep) < limit and any(depth < len(q) for q in queues):
|
|
1177
|
+
for queue in queues:
|
|
1178
|
+
if depth < len(queue) and len(keep) < limit:
|
|
1179
|
+
keep.add(queue[depth])
|
|
1180
|
+
depth += 1
|
|
1181
|
+
return [opp for index, opp in enumerate(opportunities) if index in keep]
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def detection_coverage(
|
|
1185
|
+
findings: list[Finding], opportunities: list[DetectionOpportunity]
|
|
1186
|
+
) -> list[dict[str, object]]:
|
|
1187
|
+
"""Per finding class (rule id) in this report: how many deployable rules
|
|
1188
|
+
cover it and from which log sources (#251)."""
|
|
1189
|
+
rows: list[dict[str, object]] = []
|
|
1190
|
+
seen: set[str] = set()
|
|
1191
|
+
for finding in findings:
|
|
1192
|
+
rule_id = finding.rule_id
|
|
1193
|
+
if not rule_id or rule_id in seen or rule_id in INFORMATIONAL_RULES:
|
|
1194
|
+
continue
|
|
1195
|
+
seen.add(rule_id)
|
|
1196
|
+
covering = [o for o in opportunities if o.sigma is not None and rule_id in o.related_rule_ids]
|
|
1197
|
+
sources: list[str] = []
|
|
1198
|
+
for opp in covering:
|
|
1199
|
+
label = opp.logsource.label() if opp.logsource else "unknown"
|
|
1200
|
+
if label not in sources:
|
|
1201
|
+
sources.append(label)
|
|
1202
|
+
rows.append(
|
|
1203
|
+
{
|
|
1204
|
+
"rule_id": rule_id,
|
|
1205
|
+
"title": finding.title,
|
|
1206
|
+
"sigma_rules": len(covering),
|
|
1207
|
+
"correlation_rules": sum(1 for o in covering if o.sigma_correlation is not None),
|
|
1208
|
+
"logsources": sources,
|
|
1209
|
+
}
|
|
1210
|
+
)
|
|
1211
|
+
return rows
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
_UBIQUITOUS = frozenset({"T1190"})
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _parent(technique_id: str) -> str:
|
|
1218
|
+
return technique_id.split(".", 1)[0]
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
__all__ = ["SIGMA_POINTER", "detection_coverage", "generate_detection_opportunities"]
|