attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/topology.py ADDED
@@ -0,0 +1,316 @@
1
+ """Service topology and trust-boundary graph (#18).
2
+
3
+ The threat-model chain builders (`_build_service_chains`,
4
+ `_build_atproto_chains`) each walk analyzer hints ad-hoc to reason about
5
+ service-to-service relationships. This module lifts that walk into a
6
+ reusable, inspectable **graph structure** so:
7
+
8
+ - consumers (LLM prompts, JSON reports, review pipelines) can see the
9
+ topology without reconstructing it themselves,
10
+ - future chain builders share one source of truth for "which services
11
+ exist and how are they connected",
12
+ - the shape is testable in isolation.
13
+
14
+ The graph is computed on demand (`build_service_graph(scan)`). It is
15
+ *not* attached to `ScanResult` — additive-only, no schema change to the
16
+ analyzer contract or serialization surface.
17
+
18
+ ## Node kinds
19
+
20
+ Nodes represent services. A service is identified by its slug — the
21
+ name analyzers emit via `service_name:<slug>` hints, or inferred from
22
+ repository layout (`services/<name>/…`, `packages/<name>/…`, or
23
+ `apps/<name>/…`).
24
+
25
+ ## Edge kinds
26
+
27
+ - ``http-call`` — outbound HTTP call to a host that looks like a
28
+ named peer service (e.g. `https://worker.internal/…`).
29
+ - ``env-configured`` — outbound configured via an environment
30
+ variable that names a peer (`WORKER_URL`, `FEEDGEN_URL`).
31
+ - ``analyzer-declared`` — an analyzer explicitly emitted
32
+ `edge:<from>-><to>` (e.g. from node-service's inter-service HTTP
33
+ or async worker code).
34
+ - ``async-queue`` — a BullMQ / Kafka topic edge (`queue://bullmq/…`
35
+ or `queue://kafka/…` targets emitted by node-service 0.2.0).
36
+
37
+ Each edge carries a `file` citation so the topology stays evidence-backed.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from dataclasses import dataclass, field
43
+ from typing import Literal
44
+ from urllib.parse import urlparse
45
+
46
+ from .models import ScanResult
47
+
48
+
49
+ EdgeKind = Literal["http-call", "env-configured", "analyzer-declared", "async-queue"]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ServiceNode:
54
+ name: str
55
+ role: str = "service"
56
+ files_seen: tuple[str, ...] = ()
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ServiceEdge:
61
+ source: str
62
+ target: str
63
+ kind: EdgeKind
64
+ file: str
65
+ evidence: str = ""
66
+
67
+
68
+ @dataclass
69
+ class ServiceGraph:
70
+ """A snapshot of the services + trust boundaries visible in a scan.
71
+
72
+ Node ordering: first-seen wins (in the order signals appear across
73
+ the input scan). Edges dedup by ``(source, target, kind)`` — a peer
74
+ reached both via HTTP and via env-configured URL shows up as two
75
+ edges.
76
+ """
77
+
78
+ nodes: list[ServiceNode] = field(default_factory=list)
79
+ edges: list[ServiceEdge] = field(default_factory=list)
80
+
81
+ def node_names(self) -> set[str]:
82
+ return {n.name for n in self.nodes}
83
+
84
+ def has_node(self, name: str) -> bool:
85
+ return any(n.name == name for n in self.nodes)
86
+
87
+ def peers_of(self, name: str) -> list[str]:
88
+ """Direct outbound neighbors of `name`, ordered as edges were added."""
89
+ seen: set[str] = set()
90
+ out: list[str] = []
91
+ for e in self.edges:
92
+ if e.source == name and e.target not in seen:
93
+ seen.add(e.target)
94
+ out.append(e.target)
95
+ return out
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Graph construction
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def build_service_graph(scan: ScanResult) -> ServiceGraph:
104
+ """Compute the service topology visible in `scan`.
105
+
106
+ Purely heuristic; no protocol-specific parsing. Consumes hints the
107
+ analyzer plugins already emit — `service_name:*`, `service_role:*`,
108
+ `edge:src->dst`, plus outbound URLs and env-configured endpoints.
109
+ Repo layout (`services/*`, `packages/*`, `apps/*`) is used as a
110
+ fallback when no explicit `service_name:` hint fires for a file.
111
+ """
112
+ graph = ServiceGraph()
113
+ nodes: dict[str, ServiceNode] = {}
114
+ file_to_service = _file_service_map(scan)
115
+
116
+ def _record_node(name: str, role: str = "service", file: str | None = None) -> None:
117
+ name = name.strip().lower()
118
+ if not name:
119
+ return
120
+ existing = nodes.get(name)
121
+ files = list(existing.files_seen) if existing else []
122
+ if file and file not in files:
123
+ files.append(file)
124
+ role_final = existing.role if existing and existing.role != "service" else role
125
+ nodes[name] = ServiceNode(name=name, role=role_final, files_seen=tuple(files))
126
+
127
+ # 1) Service nodes from explicit `service_name:*` hints.
128
+ for hint in _iter_hints(scan):
129
+ if hint[0].startswith("service_name:"):
130
+ name = hint[0].removeprefix("service_name:")
131
+ _record_node(name, file=hint[1])
132
+ # 2) Roles from `service_role:*` hints.
133
+ for hint in _iter_hints(scan):
134
+ if hint[0].startswith("service_role:"):
135
+ role = hint[0].removeprefix("service_role:")
136
+ # Attach role to whichever service the same file names.
137
+ for file_service_name in _services_in_file(hint[1], scan):
138
+ _record_node(file_service_name, role=role, file=hint[1])
139
+ # 3) Fallback nodes from repo layout: each unique file in services/*,
140
+ # packages/*, apps/* implies a node named after the directory.
141
+ for path in _files_in_scan(scan):
142
+ inferred = _infer_from_layout(path)
143
+ if inferred:
144
+ _record_node(inferred, file=path)
145
+
146
+ # 4) Edges from explicit `edge:src->dst` hints (analyzer-declared).
147
+ seen_edges: set[tuple[str, str, str]] = set()
148
+
149
+ def _record_edge(source: str, target: str, kind: EdgeKind, file: str, evidence: str = "") -> None:
150
+ source = source.strip().lower()
151
+ target = target.strip().lower()
152
+ if not source or not target or source == target:
153
+ return
154
+ key = (source, target, kind)
155
+ if key in seen_edges:
156
+ return
157
+ seen_edges.add(key)
158
+ graph.edges.append(ServiceEdge(source=source, target=target, kind=kind, file=file, evidence=evidence))
159
+ # Auto-create sink nodes so the target is inspectable even when
160
+ # the analyzer never saw its own code (external peer).
161
+ _record_node(source, file=file)
162
+ _record_node(target, file=file)
163
+
164
+ for hint_value, hint_file in _iter_hints(scan):
165
+ if not hint_value.startswith("edge:"):
166
+ continue
167
+ payload = hint_value.removeprefix("edge:")
168
+ if "->" not in payload:
169
+ continue
170
+ src, dst = payload.split("->", 1)
171
+ _record_edge(src, dst, "analyzer-declared", hint_file, evidence=hint_value)
172
+
173
+ # 5) HTTP-call edges — outbound URLs whose host looks like a peer.
174
+ for call in scan.external_calls:
175
+ target = call.target
176
+ # Async queues (node-service 0.2.0 emits `queue://bullmq/<name>`
177
+ # and `queue://kafka/<topic>` as external_calls). Model these
178
+ # as their own edge kind so consumers can distinguish sync/async.
179
+ if target.startswith("queue://"):
180
+ proto_name = target.removeprefix("queue://").split("/", 1)
181
+ if len(proto_name) == 2:
182
+ proto, name = proto_name
183
+ source = _service_owning_file(call.file, file_to_service)
184
+ if source:
185
+ _record_edge(
186
+ source,
187
+ f"{proto}:{name}",
188
+ "async-queue",
189
+ call.file,
190
+ evidence=target,
191
+ )
192
+ continue
193
+ # env://VAR_URL pseudo-target from the node-service analyzer.
194
+ if target.startswith("env://"):
195
+ env_name = target.removeprefix("env://")
196
+ peer = _service_from_env_name(env_name)
197
+ source = _service_owning_file(call.file, file_to_service)
198
+ if source and peer:
199
+ _record_edge(source, peer, "env-configured", call.file, evidence=target)
200
+ continue
201
+ # Ordinary URL. Match host to a known service if possible.
202
+ peer = _service_from_url(target)
203
+ source = _service_owning_file(call.file, file_to_service)
204
+ if source and peer:
205
+ _record_edge(source, peer, "http-call", call.file, evidence=target)
206
+
207
+ # Emit nodes in a stable order — insertion order.
208
+ graph.nodes = list(nodes.values())
209
+ return graph
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Helpers
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _iter_hints(scan: ScanResult):
218
+ """Yield (hint, file) across all hint families the analyzers use to
219
+ describe service structure."""
220
+ for hint in scan.auth_hints:
221
+ yield (hint.hint, hint.file)
222
+ for hint in scan.service_hints:
223
+ yield (hint.hint, hint.file)
224
+ for hint in scan.edge_hints:
225
+ yield (hint.hint, hint.file)
226
+
227
+
228
+ def _files_in_scan(scan: ScanResult) -> set[str]:
229
+ files: set[str] = set()
230
+ for r in scan.routes:
231
+ files.add(r.file)
232
+ for c in scan.external_calls:
233
+ files.add(c.file)
234
+ for d in scan.databases:
235
+ files.add(d.file)
236
+ for h in scan.auth_hints:
237
+ files.add(h.file)
238
+ return files
239
+
240
+
241
+ def _infer_from_layout(path: str) -> str | None:
242
+ normalized = path.replace("\\", "/")
243
+ parts = normalized.split("/")
244
+ for parent in ("services", "packages", "apps"):
245
+ if parent in parts:
246
+ idx = parts.index(parent)
247
+ if idx + 1 < len(parts):
248
+ return parts[idx + 1].lower()
249
+ return None
250
+
251
+
252
+ def _file_service_map(scan: ScanResult) -> dict[str, str]:
253
+ """Best-guess service name per file, resolved by explicit
254
+ ``service_name:<slug>`` hints first, then repo-layout fallback."""
255
+ mapping: dict[str, str] = {}
256
+ for hint_value, hint_file in _iter_hints(scan):
257
+ if hint_value.startswith("service_name:"):
258
+ name = hint_value.removeprefix("service_name:").lower()
259
+ if name and hint_file not in mapping:
260
+ mapping[hint_file] = name
261
+ return mapping
262
+
263
+
264
+ def _services_in_file(file: str, scan: ScanResult) -> list[str]:
265
+ names: list[str] = []
266
+ seen: set[str] = set()
267
+ for hint_value, hint_file in _iter_hints(scan):
268
+ if hint_file != file:
269
+ continue
270
+ if not hint_value.startswith("service_name:"):
271
+ continue
272
+ name = hint_value.removeprefix("service_name:").lower()
273
+ if name and name not in seen:
274
+ seen.add(name)
275
+ names.append(name)
276
+ return names
277
+
278
+
279
+ def _service_owning_file(file: str, file_to_service: dict[str, str]) -> str | None:
280
+ if file in file_to_service:
281
+ return file_to_service[file]
282
+ return _infer_from_layout(file)
283
+
284
+
285
+ def _service_from_url(target: str) -> str | None:
286
+ """Extract a service slug from an outbound URL. Mirrors the same
287
+ lightweight heuristic the node-service analyzer uses so both agree
288
+ on what constitutes a peer name."""
289
+ try:
290
+ host = urlparse(target).hostname or ""
291
+ except ValueError:
292
+ return None
293
+ if not host:
294
+ return None
295
+ if host in {"localhost", "127.0.0.1"}:
296
+ return "local-service"
297
+ return host.split(".")[0].split("-")[0].lower()
298
+
299
+
300
+ def _service_from_env_name(env_name: str) -> str | None:
301
+ token = env_name.upper().removesuffix("_URL")
302
+ if token.endswith("_SERVICE"):
303
+ token = token.removesuffix("_SERVICE")
304
+ token = token.replace("__", "_").strip("_")
305
+ if not token:
306
+ return None
307
+ return token.lower().replace("_", "-")
308
+
309
+
310
+ __all__ = [
311
+ "ServiceGraph",
312
+ "ServiceNode",
313
+ "ServiceEdge",
314
+ "EdgeKind",
315
+ "build_service_graph",
316
+ ]
attackmap/triage.py ADDED
@@ -0,0 +1,151 @@
1
+ """Deterministic triage fallback (#145).
2
+
3
+ `--triage` asks an LLM to cluster/dedupe/rank the *existing* heuristic findings
4
+ into a prioritized shortlist. When no LLM backend is available it must still
5
+ produce a useful, **reproducible** shortlist rather than erroring — that is what
6
+ this module does: a score-ordered, root-cause-clustered Markdown view that cites
7
+ each finding's stable `finding_id`.
8
+
9
+ The ordering is a pure function of the findings (severity → exploitability →
10
+ score → title), so two runs over the same findings produce byte-identical
11
+ output and the file diffs cleanly across scans.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .diff import finding_id
17
+ from .models import Finding, ScanResult
18
+
19
+ _SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
20
+
21
+ # Root-cause clusters, in fixed priority order. Each maps to the finding tags
22
+ # (substring match) that belong to it. The first matching cluster wins.
23
+ _CLUSTERS: tuple[tuple[str, tuple[str, ...]], ...] = (
24
+ ("Authorization & access control", ("broken-authorization", "auth-missing", "authorization")),
25
+ ("Injection & unsafe data flow", ("injection", "taint", "ssrf", "ssti", "nosql", "sql", "command", "deserialization", "open-redirect", "traversal")),
26
+ ("Secret exposure", ("secret-exposure", "secret")),
27
+ ("Vulnerable dependencies", ("cve", "dependency")),
28
+ ("Insecure cryptography", ("crypto", "randomness", "tls")),
29
+ ("Web / transport hardening", ("web-hardening", "hardening", "cors", "cookie", "header")),
30
+ ("CI / workflow security", ("workflow", "ci")),
31
+ ("Exposed endpoints & integration", ("exposed-endpoint", "integration-risk", "service-chain", "framework-chain", "atproto-chain")),
32
+ )
33
+ _FALLBACK_CLUSTER = "Other findings"
34
+
35
+
36
+ def _sort_key(finding: Finding) -> tuple:
37
+ """Deterministic priority key: severity, then exploitability, then score,
38
+ then title (a total order, so output is reproducible)."""
39
+ return (
40
+ _SEVERITY_RANK.get(finding.severity, 3),
41
+ -(finding.exploitability or 0),
42
+ -(finding.score or 0),
43
+ finding.title,
44
+ )
45
+
46
+
47
+ def _cluster_of(finding: Finding) -> tuple[int, str]:
48
+ """Return ``(priority, label)`` for a finding's root-cause cluster."""
49
+ tags = {t.lower() for t in finding.tags}
50
+ for priority, (label, markers) in enumerate(_CLUSTERS):
51
+ if any(any(m in tag for tag in tags) for m in markers):
52
+ return priority, label
53
+ return len(_CLUSTERS), _FALLBACK_CLUSTER
54
+
55
+
56
+ def rank_findings(findings: list[Finding]) -> list[Finding]:
57
+ """Findings de-duplicated by ``finding_id`` and ordered deterministically."""
58
+ seen: set[str] = set()
59
+ unique: list[Finding] = []
60
+ for finding in findings:
61
+ fid = finding_id(finding.title)
62
+ if fid in seen:
63
+ continue
64
+ seen.add(fid)
65
+ unique.append(finding)
66
+ return sorted(unique, key=_sort_key)
67
+
68
+
69
+ def _metrics_suffix(finding: Finding) -> str:
70
+ bits = [finding.severity]
71
+ if finding.exploitability is not None:
72
+ bits.append(f"exploitability {finding.exploitability}")
73
+ elif finding.score is not None:
74
+ bits.append(f"score {finding.score}")
75
+ return ", ".join(bits)
76
+
77
+
78
+ def _rationale(finding: Finding, cluster_size: int) -> str:
79
+ if finding.severity == "high":
80
+ lead = "HIGH severity"
81
+ elif finding.severity == "medium":
82
+ lead = "medium severity"
83
+ else:
84
+ lead = "low severity"
85
+ if finding.exploitability is not None:
86
+ lead += f", exploitability {finding.exploitability}/100"
87
+ if cluster_size > 1:
88
+ lead += f"; clusters with {cluster_size - 1} related finding(s)"
89
+ return lead
90
+
91
+
92
+ def render_triage_fallback(scan: ScanResult, findings: list[Finding]) -> str:
93
+ """Render a deterministic, clustered, ranked triage shortlist in Markdown."""
94
+ ranked = rank_findings(findings)
95
+ if not ranked:
96
+ return (
97
+ "# AttackMap triage shortlist\n\n"
98
+ "_Deterministic prioritization (no LLM backend available)._\n\n"
99
+ "No heuristic findings to triage.\n"
100
+ )
101
+
102
+ # Group into clusters, preserving each finding's global rank order.
103
+ clusters: dict[str, list[Finding]] = {}
104
+ cluster_priority: dict[str, int] = {}
105
+ for finding in ranked:
106
+ priority, label = _cluster_of(finding)
107
+ clusters.setdefault(label, []).append(finding)
108
+ cluster_priority[label] = priority
109
+
110
+ # Order clusters by the FULL priority of their best finding (severity, then
111
+ # exploitability, then score), then fixed cluster priority as a stable
112
+ # tiebreaker — so the leading cluster is the one containing the top-ranked
113
+ # finding, matching the "Start here" summary below.
114
+ ordered_labels = sorted(
115
+ clusters,
116
+ key=lambda label: (_sort_key(clusters[label][0]), cluster_priority[label], label),
117
+ )
118
+
119
+ lines: list[str] = [
120
+ "# AttackMap triage shortlist",
121
+ "",
122
+ f"_Deterministic prioritization of {len(ranked)} existing finding(s) "
123
+ "(no LLM backend available). Ordered by severity, exploitability, then "
124
+ "score; clustered by root cause._",
125
+ "",
126
+ ]
127
+
128
+ rank = 0
129
+ for label in ordered_labels:
130
+ group = clusters[label]
131
+ lines.append(f"## {label} ({len(group)})")
132
+ lines.append("")
133
+ for finding in group:
134
+ rank += 1
135
+ fid = finding_id(finding.title)
136
+ lines.append(
137
+ f"{rank}. `{fid}` — **{finding.title}** ({_metrics_suffix(finding)})"
138
+ )
139
+ lines.append(f" - {_rationale(finding, len(group))}")
140
+ lines.append("")
141
+
142
+ top = ranked[0]
143
+ _, top_label = _cluster_of(top)
144
+ lines.append("## Start here")
145
+ lines.append("")
146
+ lines.append(
147
+ f"Top priority: `{finding_id(top.title)}` — **{top.title}** "
148
+ f"(cluster: {top_label}). Work the shortlist top-down."
149
+ )
150
+ lines.append("")
151
+ return "\n".join(lines)