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