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/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
+ ]