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/diff.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Baseline / diff mode for CI integration (#47).
|
|
2
|
+
|
|
3
|
+
The design goal is a lightweight PR-integration path that pairs well with
|
|
4
|
+
SARIF (#42): a bot comment on the PR, a JSON diff, or a gate that fails
|
|
5
|
+
the pipeline on newly-introduced HIGH findings — without needing GitHub
|
|
6
|
+
Code Scanning.
|
|
7
|
+
|
|
8
|
+
## Finding identity
|
|
9
|
+
|
|
10
|
+
We hash the finding title alone. AttackMap emits one ``Finding`` per
|
|
11
|
+
issue-type (a webhook-without-auth finding aggregates every unauthenticated
|
|
12
|
+
webhook it saw; a hardcoded-secret finding aggregates every hit). Line
|
|
13
|
+
numbers and per-site evidence drift on unrelated commits — the title
|
|
14
|
+
carries the semantic identity that survives that drift.
|
|
15
|
+
|
|
16
|
+
## Diff semantics
|
|
17
|
+
|
|
18
|
+
Given (baseline, current):
|
|
19
|
+
|
|
20
|
+
- **new** — id in current but not in baseline
|
|
21
|
+
- **persisted** — id in both (current-side snapshot preserved for context)
|
|
22
|
+
- **resolved** — id in baseline but not in current
|
|
23
|
+
|
|
24
|
+
We do not track "changed" findings separately. Downstream tooling can
|
|
25
|
+
compare per-field on ``persisted`` if it cares (severity uplift, more
|
|
26
|
+
evidence, etc.); representing "changed" here would double-count.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
from collections.abc import Iterable
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from .md import md_code, md_text
|
|
40
|
+
from .models import Finding
|
|
41
|
+
from .taxonomy import taxonomy_label
|
|
42
|
+
|
|
43
|
+
_SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def finding_id(title: str) -> str:
|
|
47
|
+
"""Stable per-finding identifier.
|
|
48
|
+
|
|
49
|
+
Truncated sha256 of the title, hex-encoded. 16 hex chars = 64 bits
|
|
50
|
+
of identity — comfortably unique across the space of real finding
|
|
51
|
+
titles emitted by AttackMap.
|
|
52
|
+
"""
|
|
53
|
+
return hashlib.sha256(title.encode("utf-8")).hexdigest()[:16]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class FindingSnapshot:
|
|
58
|
+
"""Diff-friendly view of one finding."""
|
|
59
|
+
|
|
60
|
+
id: str
|
|
61
|
+
title: str
|
|
62
|
+
severity: str
|
|
63
|
+
confidence: str
|
|
64
|
+
tags: tuple[str, ...] = ()
|
|
65
|
+
evidence: tuple[str, ...] = ()
|
|
66
|
+
# Per-instance identity (#222): fingerprint -> "file:line" label. Empty for
|
|
67
|
+
# findings without fingerprinted locations (e.g. pre-0.4.32 baselines).
|
|
68
|
+
instances: tuple[tuple[str, str], ...] = ()
|
|
69
|
+
# Stable rule id (#223), used to label taxonomy ids in Markdown (#250).
|
|
70
|
+
rule_id: str | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def taxonomy_suffix(self) -> str:
|
|
74
|
+
label = taxonomy_label(self.rule_id)
|
|
75
|
+
return f" ({label})" if label else ""
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_finding(cls, f: Finding) -> "FindingSnapshot":
|
|
79
|
+
return cls(
|
|
80
|
+
id=finding_id(f.title),
|
|
81
|
+
title=f.title,
|
|
82
|
+
severity=f.severity,
|
|
83
|
+
confidence=f.confidence,
|
|
84
|
+
tags=tuple(f.tags),
|
|
85
|
+
evidence=tuple(f.evidence),
|
|
86
|
+
instances=tuple(
|
|
87
|
+
(loc.fingerprint, f"{loc.file}:{loc.line}" if loc.line else loc.file)
|
|
88
|
+
for loc in f.locations
|
|
89
|
+
if loc.fingerprint
|
|
90
|
+
),
|
|
91
|
+
rule_id=f.rule_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_dump(cls, d: dict[str, Any]) -> "FindingSnapshot":
|
|
96
|
+
title = str(d.get("title", ""))
|
|
97
|
+
instances = []
|
|
98
|
+
for loc in d.get("locations") or ():
|
|
99
|
+
if isinstance(loc, dict) and loc.get("fingerprint"):
|
|
100
|
+
label = f"{loc.get('file')}:{loc['line']}" if loc.get("line") else str(loc.get("file"))
|
|
101
|
+
instances.append((str(loc["fingerprint"]), label))
|
|
102
|
+
return cls(
|
|
103
|
+
id=finding_id(title),
|
|
104
|
+
title=title,
|
|
105
|
+
severity=str(d.get("severity", "medium")),
|
|
106
|
+
confidence=str(d.get("confidence", "medium")),
|
|
107
|
+
tags=tuple(d.get("tags") or ()),
|
|
108
|
+
evidence=tuple(d.get("evidence") or ()),
|
|
109
|
+
instances=tuple(instances),
|
|
110
|
+
rule_id=d.get("rule_id") if isinstance(d.get("rule_id"), str) else None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class DiffReport:
|
|
116
|
+
new: list[FindingSnapshot] = field(default_factory=list)
|
|
117
|
+
persisted: list[FindingSnapshot] = field(default_factory=list)
|
|
118
|
+
resolved: list[FindingSnapshot] = field(default_factory=list)
|
|
119
|
+
# Persisted findings that gained instances the baseline didn't have (#222):
|
|
120
|
+
# (finding, labels of the new instances).
|
|
121
|
+
new_instances: list[tuple[FindingSnapshot, list[str]]] = field(default_factory=list)
|
|
122
|
+
# Active in the baseline, suppressed now (#224): not a fix, so not
|
|
123
|
+
# "resolved" — surfaced separately and optionally gated.
|
|
124
|
+
newly_suppressed: list[FindingSnapshot] = field(default_factory=list)
|
|
125
|
+
# Active in the baseline, and every instance now sits under a directory
|
|
126
|
+
# the scan skipped because of an output marker (GHSA-j544-8x8g-c3mw): not
|
|
127
|
+
# resolved, the code was never looked at. Gated with --fail-on-new-suppression.
|
|
128
|
+
newly_excluded: list[FindingSnapshot] = field(default_factory=list)
|
|
129
|
+
# True when the baseline predates fingerprints, so only whole new
|
|
130
|
+
# findings (not new instances) could be detected.
|
|
131
|
+
baseline_without_instances: bool = False
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def has_new_high(self) -> bool:
|
|
135
|
+
return any(s.severity == "high" for s in self.new) or any(
|
|
136
|
+
s.severity == "high" for s, _ in self.new_instances
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def counts(self) -> dict[str, int]:
|
|
140
|
+
return {
|
|
141
|
+
"new": len(self.new),
|
|
142
|
+
"persisted": len(self.persisted),
|
|
143
|
+
"resolved": len(self.resolved),
|
|
144
|
+
"new_instances": sum(len(labels) for _, labels in self.new_instances),
|
|
145
|
+
"newly_suppressed": len(self.newly_suppressed),
|
|
146
|
+
"newly_excluded": len(self.newly_excluded),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _under_excluded(snapshot: FindingSnapshot, excluded_dirs: tuple[str, ...]) -> bool:
|
|
151
|
+
"""True when the finding has instances and every one of them is in a file
|
|
152
|
+
under one of ``excluded_dirs`` (repo-relative POSIX directory paths)."""
|
|
153
|
+
if not snapshot.instances or not excluded_dirs:
|
|
154
|
+
return False
|
|
155
|
+
for _fp, label in snapshot.instances:
|
|
156
|
+
file = label.rsplit(":", 1)[0] if re.search(r":\d+$", label) else label
|
|
157
|
+
if not any(file == d or file.startswith(d + "/") for d in excluded_dirs):
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def diff_findings(
|
|
163
|
+
baseline: list[FindingSnapshot],
|
|
164
|
+
current: list[FindingSnapshot],
|
|
165
|
+
suppressed: list[FindingSnapshot] | None = None,
|
|
166
|
+
*,
|
|
167
|
+
excluded_dirs: Iterable[str] = (),
|
|
168
|
+
) -> DiffReport:
|
|
169
|
+
"""``suppressed``: findings suppressed in the current run. One that was
|
|
170
|
+
active in the baseline is *newly suppressed*, not resolved (#224).
|
|
171
|
+
``excluded_dirs``: directories this run skipped because of an output
|
|
172
|
+
marker; a baseline finding whose instances all lived there is *newly
|
|
173
|
+
excluded*, not resolved (GHSA-j544-8x8g-c3mw)."""
|
|
174
|
+
base_by_id = {s.id: s for s in baseline}
|
|
175
|
+
cur_ids = {s.id for s in current}
|
|
176
|
+
suppressed_ids = {s.id for s in suppressed or []}
|
|
177
|
+
excluded = tuple(d.strip("/") for d in excluded_dirs if d.strip("/"))
|
|
178
|
+
gone = [s for s in baseline if s.id not in cur_ids]
|
|
179
|
+
report = DiffReport(
|
|
180
|
+
new=[s for s in current if s.id not in base_by_id],
|
|
181
|
+
persisted=[s for s in current if s.id in base_by_id],
|
|
182
|
+
resolved=[
|
|
183
|
+
s for s in gone if s.id not in suppressed_ids and not _under_excluded(s, excluded)
|
|
184
|
+
],
|
|
185
|
+
newly_suppressed=[s for s in gone if s.id in suppressed_ids],
|
|
186
|
+
newly_excluded=[
|
|
187
|
+
s for s in gone if s.id not in suppressed_ids and _under_excluded(s, excluded)
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
report.baseline_without_instances = bool(baseline) and not any(s.instances for s in baseline)
|
|
191
|
+
for snap in report.persisted:
|
|
192
|
+
base = base_by_id[snap.id]
|
|
193
|
+
if not base.instances or not snap.instances:
|
|
194
|
+
continue # nothing to compare at instance level
|
|
195
|
+
known = {fp for fp, _ in base.instances}
|
|
196
|
+
added = [label for fp, label in snap.instances if fp not in known]
|
|
197
|
+
if added:
|
|
198
|
+
report.new_instances.append((snap, added))
|
|
199
|
+
return report
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_baseline(path: str | Path) -> list[FindingSnapshot]:
|
|
203
|
+
"""Parse a prior ``attackmap-report.json``; return finding snapshots.
|
|
204
|
+
|
|
205
|
+
Tolerant of both the full report shape and a bare ``[Finding, …]``
|
|
206
|
+
list — useful for teams that persist just the findings section.
|
|
207
|
+
"""
|
|
208
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
209
|
+
data = json.loads(raw)
|
|
210
|
+
if isinstance(data, list):
|
|
211
|
+
return [FindingSnapshot.from_dump(d) for d in data if isinstance(d, dict)]
|
|
212
|
+
if isinstance(data, dict):
|
|
213
|
+
findings = data.get("findings") or []
|
|
214
|
+
return [FindingSnapshot.from_dump(d) for d in findings if isinstance(d, dict)]
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def render_diff_markdown(diff: DiffReport, *, title: str = "AttackMap diff") -> str:
|
|
219
|
+
"""PR-comment-shaped Markdown summary of a diff."""
|
|
220
|
+
counts = diff.counts()
|
|
221
|
+
|
|
222
|
+
def _sorted(items: list[FindingSnapshot]) -> list[FindingSnapshot]:
|
|
223
|
+
return sorted(items, key=lambda s: (_SEVERITY_RANK.get(s.severity, 3), s.title))
|
|
224
|
+
|
|
225
|
+
def _bullets(items: list[FindingSnapshot]) -> str:
|
|
226
|
+
if not items:
|
|
227
|
+
return "_none_"
|
|
228
|
+
lines = []
|
|
229
|
+
for s in _sorted(items):
|
|
230
|
+
# Titles and evidence embed repo-derived text: escape it (#233).
|
|
231
|
+
lines.append(f"- **[{s.severity.upper()}]** {md_text(s.title)}{s.taxonomy_suffix}")
|
|
232
|
+
if s.evidence:
|
|
233
|
+
# First evidence line only — keeps the comment scannable.
|
|
234
|
+
lines.append(f" - _e.g._ {md_code(s.evidence[0])}")
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
def _instance_bullets() -> str:
|
|
238
|
+
if not diff.new_instances:
|
|
239
|
+
return "_none_"
|
|
240
|
+
lines = []
|
|
241
|
+
for snap, labels in sorted(diff.new_instances, key=lambda t: (_SEVERITY_RANK.get(t[0].severity, 3), t[0].title)):
|
|
242
|
+
lines.append(f"- **[{snap.severity.upper()}]** {md_text(snap.title)} — {len(labels)} new instance(s)")
|
|
243
|
+
lines.extend(f" - {md_code(label)}" for label in labels[:10])
|
|
244
|
+
if len(labels) > 10:
|
|
245
|
+
lines.append(f" - +{len(labels) - 10} more")
|
|
246
|
+
return "\n".join(lines)
|
|
247
|
+
|
|
248
|
+
parts = [
|
|
249
|
+
f"# {title}",
|
|
250
|
+
"",
|
|
251
|
+
(
|
|
252
|
+
f"**{counts['new']} new** · "
|
|
253
|
+
f"**{counts['new_instances']} new instance(s) of existing findings** · "
|
|
254
|
+
f"**{counts['persisted']} persisted** · "
|
|
255
|
+
f"**{counts['resolved']} resolved** · "
|
|
256
|
+
f"**{counts['newly_suppressed']} newly suppressed**"
|
|
257
|
+
+ (f" · **{counts['newly_excluded']} newly excluded from the scan**" if diff.newly_excluded else "")
|
|
258
|
+
),
|
|
259
|
+
"",
|
|
260
|
+
"## New findings",
|
|
261
|
+
_bullets(diff.new),
|
|
262
|
+
"",
|
|
263
|
+
"## New instances of existing findings",
|
|
264
|
+
_instance_bullets(),
|
|
265
|
+
"",
|
|
266
|
+
"## Resolved findings",
|
|
267
|
+
_bullets(diff.resolved),
|
|
268
|
+
"",
|
|
269
|
+
"## Newly suppressed findings",
|
|
270
|
+
_bullets(diff.newly_suppressed),
|
|
271
|
+
"",
|
|
272
|
+
]
|
|
273
|
+
if diff.newly_excluded:
|
|
274
|
+
parts += [
|
|
275
|
+
"## Newly excluded from the scan",
|
|
276
|
+
"_Active in the baseline; every instance is now under a directory skipped "
|
|
277
|
+
"because of an `.attackmap-output` marker, so the code was not analyzed._",
|
|
278
|
+
_bullets(diff.newly_excluded),
|
|
279
|
+
"",
|
|
280
|
+
]
|
|
281
|
+
parts += [
|
|
282
|
+
"## Persisted findings",
|
|
283
|
+
_bullets(diff.persisted),
|
|
284
|
+
]
|
|
285
|
+
if diff.baseline_without_instances:
|
|
286
|
+
parts += [
|
|
287
|
+
"",
|
|
288
|
+
"_The baseline predates per-instance fingerprints, so new instances of "
|
|
289
|
+
"existing findings can't be detected until it is regenerated._",
|
|
290
|
+
]
|
|
291
|
+
return "\n".join(parts) + "\n"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
__all__ = [
|
|
295
|
+
"DiffReport",
|
|
296
|
+
"FindingSnapshot",
|
|
297
|
+
"diff_findings",
|
|
298
|
+
"finding_id",
|
|
299
|
+
"load_baseline",
|
|
300
|
+
"render_diff_markdown",
|
|
301
|
+
]
|