attackmap 0.1.0__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.
@@ -0,0 +1,343 @@
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. Output is intentionally
5
+ generic (rule sketches, not fully-formed Sigma/KQL/Splunk yet) so it
6
+ slots into whatever SIEM the user already runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ from .attack_taxonomy import techniques_for_finding, techniques_for_insight
14
+ from .models import (
15
+ AttackTechnique,
16
+ DetectionOpportunity,
17
+ Finding,
18
+ Insight,
19
+ InsightKind,
20
+ )
21
+
22
+
23
+ def _opportunity(
24
+ *,
25
+ opportunity_id: str,
26
+ title: str,
27
+ rationale: str,
28
+ signal_kind: str,
29
+ suggested_rule: str,
30
+ related_insight_ids: list[str] | None = None,
31
+ related_finding_titles: list[str] | None = None,
32
+ attack_techniques: list[AttackTechnique] | None = None,
33
+ ) -> DetectionOpportunity:
34
+ return DetectionOpportunity(
35
+ id=opportunity_id,
36
+ title=title,
37
+ rationale=rationale,
38
+ signal_kind=signal_kind, # type: ignore[arg-type]
39
+ suggested_rule=suggested_rule,
40
+ related_insight_ids=related_insight_ids or [],
41
+ related_finding_titles=related_finding_titles or [],
42
+ attack_techniques=attack_techniques or [],
43
+ )
44
+
45
+
46
+ # ---------- Per-insight-kind opportunity generators ----------
47
+
48
+
49
+ def _opp_for_shared_secret_blast_radius(insight: Insight) -> DetectionOpportunity:
50
+ return _opportunity(
51
+ opportunity_id=f"detect:{insight.id}",
52
+ title="Detect signing-key drift across services",
53
+ rationale=(
54
+ "Shared signing material across many services means a single rotation should be visible "
55
+ "everywhere; conversely, divergence may indicate a compromised key in use."
56
+ ),
57
+ signal_kind="config_audit",
58
+ suggested_rule=(
59
+ "Periodically hash the secret material referenced by each service and alert when "
60
+ "different services report different hashes for the same secret name (or when one "
61
+ "service uses a key version older than N days). Pair with token-issuer telemetry: "
62
+ "alert on tokens signed by a key version not present in the current authorized set."
63
+ ),
64
+ related_insight_ids=[insight.id],
65
+ attack_techniques=techniques_for_insight(insight),
66
+ )
67
+
68
+
69
+ def _opp_for_sensitive_asset_reachability(insight: Insight) -> DetectionOpportunity:
70
+ routes = ", ".join(f"`{r}`" for r in insight.related_routes[:3]) or "the listed routes"
71
+ return _opportunity(
72
+ opportunity_id=f"detect:{insight.id}",
73
+ title="Alert on unauthenticated access to sensitive-asset routes",
74
+ rationale=(
75
+ "A static-analysis gap on auth at the surface is also a runtime detection opportunity: "
76
+ "any 2xx response for these routes from a request lacking a valid session/JWT is high signal."
77
+ ),
78
+ signal_kind="log",
79
+ suggested_rule=(
80
+ f"On the access log: alert when status_code IN (200,201,204) AND route IN ({routes}) "
81
+ "AND request_headers.authorization IS NULL AND session_cookie IS NULL. "
82
+ "Tighten with a low-volume baseline — this should normally be zero."
83
+ ),
84
+ related_insight_ids=[insight.id],
85
+ attack_techniques=techniques_for_insight(insight),
86
+ )
87
+
88
+
89
+ def _opp_for_admin_action_without_auth(insight: Insight) -> DetectionOpportunity:
90
+ routes = ", ".join(f"`{r}`" for r in insight.related_routes[:3]) or "admin routes"
91
+ return _opportunity(
92
+ opportunity_id=f"detect:{insight.id}",
93
+ title="Alert on any unauthenticated state change to admin/role routes",
94
+ rationale=(
95
+ "Privilege-mutation flows are zero-tolerance for unauthenticated calls. Any successful "
96
+ "mutating verb against them from an unauthenticated session should page on first occurrence."
97
+ ),
98
+ signal_kind="log",
99
+ suggested_rule=(
100
+ f"On the access log: alert when method IN (POST,PUT,PATCH,DELETE) AND route IN ({routes}) "
101
+ "AND auth_subject IS NULL. Severity: page-on-first. Pair with an audit-log assertion that "
102
+ "every successful call to these routes carries an actor + role-change record."
103
+ ),
104
+ related_insight_ids=[insight.id],
105
+ attack_techniques=techniques_for_insight(insight),
106
+ )
107
+
108
+
109
+ def _opp_for_audit_gap(insight: Insight) -> DetectionOpportunity:
110
+ return _opportunity(
111
+ opportunity_id=f"detect:{insight.id}",
112
+ title="Establish baseline audit logs on sensitive-asset access",
113
+ rationale=(
114
+ "Detection is only as good as the telemetry available. The lack of audit-logging signals "
115
+ "in code is a precondition: there is nothing for a SIEM to reason over."
116
+ ),
117
+ signal_kind="log",
118
+ suggested_rule=(
119
+ "Emit a structured audit record `{actor, action, asset_id, outcome, request_id}` from "
120
+ "every handler that touches a sensitive asset. Forward to SIEM. Once the stream exists, "
121
+ "build per-actor anomaly detection (asset access at unusual hours, asset access from new IPs, "
122
+ "asset access volume above the per-actor 95th percentile)."
123
+ ),
124
+ related_insight_ids=[insight.id],
125
+ attack_techniques=techniques_for_insight(insight),
126
+ )
127
+
128
+
129
+ def _opp_for_defense_gap_in_chain(insight: Insight) -> DetectionOpportunity:
130
+ return _opportunity(
131
+ opportunity_id=f"detect:{insight.id}",
132
+ title="Add request-tracing to confirm controls actually run on this chain",
133
+ rationale=(
134
+ "Static analysis can't tell whether a control is invoked at runtime. Trace spans on the "
135
+ "entry and sink confirm whether intermediate auth/validation/rate-limit middleware actually executed."
136
+ ),
137
+ signal_kind="trace",
138
+ suggested_rule=(
139
+ "Instrument the chain's entry handler and its sink (DB call) with OpenTelemetry spans. "
140
+ "Alert when a sink span's parent trace lacks a span tagged `middleware=auth` or "
141
+ "`middleware=ratelimit`. This catches code paths that bypass middleware via direct invocation."
142
+ ),
143
+ related_insight_ids=[insight.id],
144
+ attack_techniques=techniques_for_insight(insight),
145
+ )
146
+
147
+
148
+ def _opp_for_control_strength_mismatch(insight: Insight) -> DetectionOpportunity:
149
+ return _opportunity(
150
+ opportunity_id=f"detect:{insight.id}",
151
+ title="Add config-drift detection for missing controls on critical assets",
152
+ rationale=(
153
+ "When critical assets exist but expected controls aren't observable, drift detection on "
154
+ "the deployed configuration (WAF rules, IAM policies, encryption-at-rest flags) gives "
155
+ "ongoing visibility even before code-level fixes land."
156
+ ),
157
+ signal_kind="config_audit",
158
+ suggested_rule=(
159
+ "Add a daily IaC/config audit step: assert that the modules holding critical assets have "
160
+ "(a) a WAF/auth policy attached, (b) database encryption-at-rest enabled, (c) audit-log "
161
+ "subscriptions configured. Page when any assertion regresses."
162
+ ),
163
+ related_insight_ids=[insight.id],
164
+ attack_techniques=techniques_for_insight(insight),
165
+ )
166
+
167
+
168
+ def _opp_for_asymmetric_protection(insight: Insight) -> DetectionOpportunity:
169
+ return _opportunity(
170
+ opportunity_id=f"detect:{insight.id}",
171
+ title="Compare per-method auth-rejection rates on the same route",
172
+ rationale=(
173
+ "Asymmetric protection often shows up as a spike in unauthenticated traffic to the "
174
+ "unprotected verb of a route whose other verbs require auth — attackers probe for the gap."
175
+ ),
176
+ signal_kind="metric",
177
+ suggested_rule=(
178
+ "Per route, alert when the ratio of (unauthenticated requests on verb X) / "
179
+ "(unauthenticated requests on verb Y) exceeds 10x for routes that share a path. "
180
+ "Suggests probing of asymmetric protection."
181
+ ),
182
+ related_insight_ids=[insight.id],
183
+ attack_techniques=techniques_for_insight(insight),
184
+ )
185
+
186
+
187
+ def _opp_for_single_point_of_failure(insight: Insight) -> DetectionOpportunity:
188
+ return _opportunity(
189
+ opportunity_id=f"detect:{insight.id}",
190
+ title="Track the blast-radius secret's last-rotated timestamp",
191
+ rationale=(
192
+ "A single auth-critical secret with no rotation cadence is a slow-burn risk; detection "
193
+ "starts with tracking how stale it has gotten."
194
+ ),
195
+ signal_kind="config_audit",
196
+ suggested_rule=(
197
+ "Pull the secret's last-modified timestamp from the secret store and emit it as a gauge metric. "
198
+ "Page when last_rotated_age_days > 90 (or your rotation policy). Pair with token-issuance "
199
+ "telemetry: alert if tokens are still being signed by a key version older than the latest."
200
+ ),
201
+ related_insight_ids=[insight.id],
202
+ attack_techniques=techniques_for_insight(insight),
203
+ )
204
+
205
+
206
+ def _opp_for_control_bypass(insight: Insight) -> DetectionOpportunity:
207
+ return _opportunity(
208
+ opportunity_id=f"detect:{insight.id}",
209
+ title="Verify every request hits the global control middleware",
210
+ rationale=(
211
+ "If a control is broadly applied but specific routes bypass it, the runtime detection is "
212
+ "to assert that every request flows through the middleware — catching the bypass without "
213
+ "needing to enumerate the specific routes."
214
+ ),
215
+ signal_kind="trace",
216
+ suggested_rule=(
217
+ "Tag the global middleware with an OTEL span and assert in the request-completion hook "
218
+ "that the request trace contains that span. Alert on missing spans in production. "
219
+ "Effective against intentional bypasses (webhook routes) and accidental ones (new route "
220
+ "added to the wrong router)."
221
+ ),
222
+ related_insight_ids=[insight.id],
223
+ attack_techniques=techniques_for_insight(insight),
224
+ )
225
+
226
+
227
+ def _opp_for_trust_boundary_violation(insight: Insight) -> DetectionOpportunity:
228
+ return _opportunity(
229
+ opportunity_id=f"detect:{insight.id}",
230
+ title="Network-level monitoring for cross-boundary traffic",
231
+ rationale=(
232
+ "An internal-only marker reachable via a public route is a structural problem; runtime "
233
+ "detection focuses on the network boundary itself rather than the application code."
234
+ ),
235
+ signal_kind="network",
236
+ suggested_rule=(
237
+ "On the perimeter (WAF/load balancer): alert on requests whose URL or Host header maps "
238
+ "to internal-only routes/services according to your service inventory. Pair with a VPC flow "
239
+ "log rule: alert on inbound traffic to internal-classified workloads from the internet egress range."
240
+ ),
241
+ related_insight_ids=[insight.id],
242
+ attack_techniques=techniques_for_insight(insight),
243
+ )
244
+
245
+
246
+ def _opp_for_stale_or_contradictory_signal(insight: Insight) -> DetectionOpportunity:
247
+ return _opportunity(
248
+ opportunity_id=f"detect:{insight.id}",
249
+ title="Annotate routes so static analysis stops contradicting itself",
250
+ rationale=(
251
+ "Stale or contradictory signals don't usually map to a runtime detection; the leverage is "
252
+ "in raising the signal-to-noise of future scans."
253
+ ),
254
+ signal_kind="config_audit",
255
+ suggested_rule=(
256
+ "Add a route-manifest test that asserts route categories (admin/public/internal/health) "
257
+ "match a single source of truth — e.g., a decorator on the handler that the test reads. "
258
+ "Future AttackMap runs will then trust the explicit annotation over heuristic guesses."
259
+ ),
260
+ related_insight_ids=[insight.id],
261
+ attack_techniques=techniques_for_insight(insight),
262
+ )
263
+
264
+
265
+ _INSIGHT_GENERATORS: dict[InsightKind, Callable[[Insight], DetectionOpportunity]] = {
266
+ "shared_secret_blast_radius": _opp_for_shared_secret_blast_radius,
267
+ "sensitive_asset_reachability": _opp_for_sensitive_asset_reachability,
268
+ "admin_action_without_auth": _opp_for_admin_action_without_auth,
269
+ "audit_gap": _opp_for_audit_gap,
270
+ "defense_gap_in_chain": _opp_for_defense_gap_in_chain,
271
+ "control_strength_mismatch": _opp_for_control_strength_mismatch,
272
+ "asymmetric_protection": _opp_for_asymmetric_protection,
273
+ "single_point_of_failure": _opp_for_single_point_of_failure,
274
+ "control_bypass": _opp_for_control_bypass,
275
+ "trust_boundary_violation": _opp_for_trust_boundary_violation,
276
+ "stale_or_contradictory_signal": _opp_for_stale_or_contradictory_signal,
277
+ }
278
+
279
+
280
+ def generate_detection_opportunities(
281
+ insights: list[Insight],
282
+ findings: list[Finding],
283
+ ) -> list[DetectionOpportunity]:
284
+ """Produce one DetectionOpportunity per insight kind observed.
285
+
286
+ Findings are folded into existing opportunities by title-keyword overlap so
287
+ we don't double-count (e.g., a heuristic finding about webhook auth and the
288
+ `sensitive_asset_reachability` insight share a detection rule).
289
+ """
290
+ opportunities: list[DetectionOpportunity] = []
291
+ seen_kinds: set[InsightKind] = set()
292
+
293
+ for insight in insights:
294
+ if insight.kind in seen_kinds:
295
+ continue
296
+ generator = _INSIGHT_GENERATORS.get(insight.kind)
297
+ if generator is None:
298
+ continue
299
+ seen_kinds.add(insight.kind)
300
+ opportunities.append(generator(insight))
301
+
302
+ if findings:
303
+ for opportunity in opportunities:
304
+ related: list[str] = []
305
+ haystack_keywords = opportunity.title.lower().split()
306
+ for finding in findings:
307
+ title_lower = finding.title.lower()
308
+ if any(keyword in title_lower for keyword in haystack_keywords if len(keyword) > 4):
309
+ related.append(finding.title)
310
+ if related:
311
+ opportunity.related_finding_titles.extend(related[:3])
312
+
313
+ finding_techniques: list[AttackTechnique] = []
314
+ seen_tids: set[str] = set()
315
+ for finding in findings:
316
+ for tech in techniques_for_finding(finding):
317
+ if tech.technique_id in seen_tids:
318
+ continue
319
+ seen_tids.add(tech.technique_id)
320
+ finding_techniques.append(tech)
321
+ if finding_techniques and not opportunities:
322
+ opportunities.append(
323
+ _opportunity(
324
+ opportunity_id="detect:findings:residual",
325
+ title="Detection coverage for findings without a paired insight",
326
+ rationale=(
327
+ "These findings did not generate cross-cutting insights but are still "
328
+ "actionable with runtime telemetry."
329
+ ),
330
+ signal_kind="log",
331
+ suggested_rule=(
332
+ "Add per-finding logging at the affected handler with a structured "
333
+ "`{finding_id, route, status_code}` payload, and aggregate in the SIEM."
334
+ ),
335
+ related_finding_titles=[f.title for f in findings[:5]],
336
+ attack_techniques=finding_techniques[:6],
337
+ )
338
+ )
339
+
340
+ return opportunities
341
+
342
+
343
+ __all__ = ["generate_detection_opportunities"]
attackmap/graph.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import networkx as nx
4
+
5
+ from .models import ScanResult
6
+
7
+
8
+ def build_graph(scan: ScanResult) -> nx.DiGraph:
9
+ graph = nx.DiGraph()
10
+
11
+ graph.add_node("repo", kind="system", label="Repository")
12
+
13
+ if scan.routes:
14
+ graph.add_node("web", kind="service", label="Web/API Layer")
15
+ graph.add_edge("repo", "web", relation="contains")
16
+
17
+ for db in {d.kind for d in scan.databases}:
18
+ graph.add_node(db, kind="database", label=db)
19
+ graph.add_edge("web" if scan.routes else "repo", db, relation="uses")
20
+
21
+ for idx, call in enumerate(scan.external_calls, start=1):
22
+ node_id = f"external_{idx}"
23
+ graph.add_node(node_id, kind="external", label=call.target)
24
+ graph.add_edge("web" if scan.routes else "repo", node_id, relation="calls")
25
+
26
+ return graph