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/safe_fs.py ADDED
@@ -0,0 +1,285 @@
1
+ """Filesystem access confined to the scanned repository (#234, #228).
2
+
3
+ AttackMap scans untrusted checkouts, so a symlink in the repo must never make
4
+ it read a file outside the repo (and report or send that file's content
5
+ elsewhere), and a symlink planted where a report is about to be written must
6
+ never redirect that write.
7
+
8
+ Read side:
9
+ * :func:`walk_repo` yields the regular files under a root without
10
+ following symlinks — symlinked files are skipped and symlinked
11
+ directories are not descended. Set ``ATTACKMAP_FOLLOW_SYMLINKS=1`` to
12
+ follow symlinks whose target resolves inside the root.
13
+ * :func:`is_contained` / :func:`contained_file` validate a path (or a
14
+ repo-relative path taken from evidence text) before it is read.
15
+ * :func:`read_repo_text` reads a file only if it is contained.
16
+
17
+ Write side:
18
+ * :func:`safe_write_text` refuses to write through a symlink or outside
19
+ the output directory, and never follows a symlink planted at the target.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from collections.abc import Callable, Iterable, Iterator
26
+ from pathlib import Path
27
+
28
+ FOLLOW_SYMLINKS_ENV = "ATTACKMAP_FOLLOW_SYMLINKS"
29
+ # Written into every report directory (#216); walk_repo never descends into a
30
+ # directory that contains it, whatever the directory is called.
31
+ OUTPUT_MARKER = ".attackmap-output"
32
+
33
+ # Marker files the current run must not honour (GHSA-j544-8x8g-c3mw): under
34
+ # --suppress-from-ref the CLI registers every marker that is not present at
35
+ # the trusted ref, so a pull request cannot exclude a directory from the scan
36
+ # by committing an empty `.attackmap-output`. Absolute, normalized paths.
37
+ _UNTRUSTED_MARKERS: set[str] = set()
38
+
39
+
40
+ def set_untrusted_output_markers(markers: Iterable[str | Path]) -> None:
41
+ """Replace the set of marker files ``walk_repo`` must ignore."""
42
+ _UNTRUSTED_MARKERS.clear()
43
+ _UNTRUSTED_MARKERS.update(os.path.abspath(str(m)) for m in markers)
44
+
45
+
46
+ def output_marker_files(root: str | Path) -> list[Path]:
47
+ """Every ``OUTPUT_MARKER`` file under ``root`` (not the root's own), as
48
+ paths relative to ``root``; symlinks are not followed."""
49
+ root_path = Path(root)
50
+ found: list[Path] = []
51
+ for dirpath, dirnames, filenames in os.walk(root_path):
52
+ dirnames[:] = sorted(d for d in dirnames if not (Path(dirpath) / d).is_symlink())
53
+ if OUTPUT_MARKER in filenames and Path(dirpath) != root_path:
54
+ marker = Path(dirpath) / OUTPUT_MARKER
55
+ if marker.is_file() and not marker.is_symlink():
56
+ found.append(marker.relative_to(root_path))
57
+ return found
58
+ MAX_FILE_BYTES_ENV = "ATTACKMAP_MAX_FILE_BYTES"
59
+ # Files larger than this are not read (#236): a hostile or generated multi-MB
60
+ # file must not stall a CI scan. Real source files are far smaller.
61
+ DEFAULT_MAX_FILE_BYTES = 2_000_000
62
+
63
+
64
+ class UnsafePathError(OSError):
65
+ """A read or write would escape the repository / output directory."""
66
+
67
+
68
+ def max_file_bytes() -> int:
69
+ raw = os.environ.get(MAX_FILE_BYTES_ENV, "").strip()
70
+ try:
71
+ value = int(raw) if raw else DEFAULT_MAX_FILE_BYTES
72
+ except ValueError:
73
+ value = DEFAULT_MAX_FILE_BYTES
74
+ return value if value > 0 else DEFAULT_MAX_FILE_BYTES
75
+
76
+
77
+ def is_oversized(path: Path) -> bool:
78
+ """True when ``path`` is larger than the per-file read cap."""
79
+ try:
80
+ return path.stat().st_size > max_file_bytes()
81
+ except OSError:
82
+ return False
83
+
84
+
85
+ def follow_symlinks_enabled() -> bool:
86
+ return os.environ.get(FOLLOW_SYMLINKS_ENV, "").strip().lower() in {"1", "true", "yes"}
87
+
88
+
89
+ def _resolved_within(root: Path, path: Path) -> bool:
90
+ try:
91
+ return path.resolve().is_relative_to(root.resolve())
92
+ except (OSError, RuntimeError): # RuntimeError: symlink loop on some Pythons
93
+ return False
94
+
95
+
96
+ def is_contained(root: str | Path, path: str | Path) -> bool:
97
+ """True when ``path`` is inside ``root`` and is not reached via a symlink.
98
+
99
+ Every component between ``root`` and ``path`` is checked with ``lstat`` —
100
+ a symlinked directory in the middle of the path disqualifies it just like
101
+ a symlinked file. With ``ATTACKMAP_FOLLOW_SYMLINKS=1`` symlinks are allowed
102
+ as long as the fully resolved path stays inside ``root``.
103
+ """
104
+ root_path = Path(root)
105
+ candidate = Path(path)
106
+ if not candidate.is_absolute():
107
+ candidate = root_path / candidate
108
+ try:
109
+ rel = candidate.relative_to(root_path)
110
+ except ValueError:
111
+ try:
112
+ rel = candidate.relative_to(root_path.resolve())
113
+ root_path = root_path.resolve()
114
+ except ValueError:
115
+ return False
116
+ if ".." in rel.parts:
117
+ return False
118
+ if follow_symlinks_enabled():
119
+ return _resolved_within(root_path, candidate)
120
+ current = root_path
121
+ for part in rel.parts:
122
+ current = current / part
123
+ if current.is_symlink():
124
+ return False
125
+ return _resolved_within(root_path, candidate)
126
+
127
+
128
+ def is_unsafe_link(root: str | Path, entry: Path) -> bool:
129
+ """For hand-rolled ``iterdir`` walkers: True when ``entry`` is a symlink
130
+ that must be skipped (always, unless follow mode is on and it resolves
131
+ inside ``root``)."""
132
+ if not entry.is_symlink():
133
+ return False
134
+ return not (follow_symlinks_enabled() and _resolved_within(Path(root), entry))
135
+
136
+
137
+ def contained_file(root: str | Path, rel: str | Path) -> Path | None:
138
+ """Resolve a repo-relative path from untrusted text (evidence, plugin
139
+ output) to a regular file inside ``root``, or ``None``.
140
+
141
+ Absolute paths and paths with ``..`` components are rejected outright.
142
+ """
143
+ rel_path = Path(rel)
144
+ if rel_path.is_absolute() or ".." in rel_path.parts or str(rel).startswith("~"):
145
+ return None
146
+ candidate = Path(root) / rel_path
147
+ if not is_contained(root, candidate):
148
+ return None
149
+ try:
150
+ return candidate if candidate.is_file() else None
151
+ except OSError:
152
+ return None
153
+
154
+
155
+ def read_repo_text(
156
+ root: str | Path, path: str | Path, *, encoding: str = "utf-8", errors: str = "strict"
157
+ ) -> str:
158
+ """``Path.read_text`` that refuses paths outside ``root`` or behind a symlink."""
159
+ candidate = Path(path)
160
+ if not candidate.is_absolute():
161
+ candidate = Path(root) / candidate
162
+ if not is_contained(root, candidate):
163
+ raise UnsafePathError(f"refusing to read outside the repository: {path}")
164
+ return candidate.read_text(encoding=encoding, errors=errors)
165
+
166
+
167
+ def walk_repo(
168
+ root: str | Path,
169
+ *,
170
+ prune: Callable[[str], bool] | None = None,
171
+ on_symlink: Callable[[Path], None] | None = None,
172
+ on_prune: Callable[[Path], None] | None = None,
173
+ ) -> Iterator[Path]:
174
+ """Yield regular files under ``root`` (depth-first, sorted per directory).
175
+
176
+ Symlinks are skipped — symlinked files are not yielded and symlinked
177
+ directories are not descended — and ``on_symlink(path)`` is called for
178
+ each. With ``ATTACKMAP_FOLLOW_SYMLINKS=1`` symlinks whose target resolves
179
+ inside ``root`` are followed (cycle-safe); ones pointing outside are still
180
+ skipped. ``prune(dirname)`` returning True skips descending into a
181
+ directory. ``on_prune(dir)`` is called for each directory skipped
182
+ because it carries an output marker, so the caller can record it.
183
+ """
184
+ root_path = Path(root)
185
+ follow = follow_symlinks_enabled()
186
+ seen_dirs: set[str] = set()
187
+ for dirpath, dirnames, filenames in os.walk(root_path, followlinks=follow):
188
+ current = Path(dirpath)
189
+ if follow:
190
+ real = os.path.realpath(dirpath)
191
+ if real in seen_dirs or not _resolved_within(root_path, current):
192
+ dirnames[:] = []
193
+ continue
194
+ seen_dirs.add(real)
195
+ kept_dirs = []
196
+ for name in sorted(dirnames):
197
+ if prune is not None and prune(name):
198
+ continue
199
+ child = current / name
200
+ # Our own report output, under any name (#216). Not checked for the
201
+ # root itself, so `-o .` can't make a repo unscannable. A marker the
202
+ # CLI registered as untrusted (added since the PR base) is ignored
203
+ # and the directory is scanned (GHSA-j544-8x8g-c3mw).
204
+ marker = child / OUTPUT_MARKER
205
+ if marker.is_file() and not marker.is_symlink():
206
+ if os.path.abspath(marker) not in _UNTRUSTED_MARKERS:
207
+ if on_prune is not None:
208
+ on_prune(child)
209
+ continue
210
+ if child.is_symlink() and not (follow and _resolved_within(root_path, child)):
211
+ if on_symlink is not None:
212
+ on_symlink(child)
213
+ continue
214
+ kept_dirs.append(name)
215
+ dirnames[:] = kept_dirs
216
+ for name in sorted(filenames):
217
+ child = current / name
218
+ if child.is_symlink() and not (follow and _resolved_within(root_path, child)):
219
+ if on_symlink is not None:
220
+ on_symlink(child)
221
+ continue
222
+ try:
223
+ if child.is_file():
224
+ yield child
225
+ except OSError:
226
+ continue
227
+
228
+
229
+ def ensure_output_dir(out_dir: str | Path, *, mark: bool = False) -> Path:
230
+ """Create the report directory, refusing a symlinked one.
231
+
232
+ No ancestor inside the current working directory (usually the checkout
233
+ being scanned) may be a symlink that leads outside it — otherwise a
234
+ committed ``reports -> /somewhere`` link would redirect every report write
235
+ (#228). Symlinks that stay inside the cwd are allowed, which also covers
236
+ system links such as macOS's ``/var`` and ``/tmp`` (→ ``/private/...``)
237
+ when the cwd is ``/``, as it is for a GUI-launched or test-runner process.
238
+ """
239
+ out = Path(out_dir)
240
+ absolute = out.absolute()
241
+ cwd = Path.cwd()
242
+ real_cwd = cwd.resolve()
243
+ for candidate in (absolute, *absolute.parents):
244
+ if candidate == cwd or not candidate.is_relative_to(cwd):
245
+ continue
246
+ if candidate.is_symlink() and not candidate.resolve().is_relative_to(real_cwd):
247
+ raise UnsafePathError(f"refusing to write reports through a symlink: {candidate}")
248
+ out.mkdir(parents=True, exist_ok=True)
249
+ if out.is_symlink():
250
+ raise UnsafePathError(f"refusing to write reports into a symlinked directory: {out}")
251
+ if mark:
252
+ marker = out / OUTPUT_MARKER
253
+ if not marker.exists() and not marker.is_symlink():
254
+ safe_write_text(
255
+ out,
256
+ marker,
257
+ "AttackMap report directory. AttackMap never scans a directory containing this file.\n",
258
+ )
259
+ return out
260
+
261
+
262
+ def safe_write_text(out_dir: str | Path, path: str | Path, text: str, *, encoding: str = "utf-8") -> None:
263
+ """Write ``text`` to ``path`` without following symlinks.
264
+
265
+ Refuses when ``path`` is (or sits behind) a symlink, or resolves outside
266
+ ``out_dir``. The final open uses ``O_NOFOLLOW`` so a symlink swapped in
267
+ after the check is not followed either.
268
+ """
269
+ out = Path(out_dir)
270
+ target = Path(path)
271
+ if out.is_symlink():
272
+ raise UnsafePathError(f"refusing to write reports into a symlinked directory: {out}")
273
+ if target.is_symlink():
274
+ raise UnsafePathError(f"refusing to write through a symlink: {target}")
275
+ if not target.parent.resolve().is_relative_to(out.resolve()):
276
+ raise UnsafePathError(f"refusing to write outside the output directory {out}: {target}")
277
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
278
+ try:
279
+ fd = os.open(target, flags, 0o644)
280
+ except OSError as exc:
281
+ if target.is_symlink():
282
+ raise UnsafePathError(f"refusing to write through a symlink: {target}") from exc
283
+ raise
284
+ with os.fdopen(fd, "w", encoding=encoding) as handle:
285
+ handle.write(text)
attackmap/sarif.py ADDED
@@ -0,0 +1,373 @@
1
+ """SARIF 2.1.0 report emission.
2
+
3
+ Turns AttackMap `Finding` records into a SARIF log that GitHub Code
4
+ Scanning (and other SARIF consumers — VS Code, Sonatype IQ, etc.)
5
+ ingest natively. Every field the finding model carries maps to a SARIF
6
+ property; nothing new is computed here — it's a serialization layer.
7
+
8
+ Reference: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import re
15
+ from urllib.parse import quote
16
+ from importlib.metadata import PackageNotFoundError, version
17
+ from typing import Any
18
+
19
+ from .srcpaths import evidence_locations
20
+ from .md import md_text
21
+ from .models import AttackPath, Finding, TaintFlowStep, finding_rule_id
22
+ from .taxonomy import CWE_NAMES, cwe_tag, cwe_url, taxonomy_for
23
+
24
+
25
+ SARIF_VERSION = "2.1.0"
26
+ SARIF_SCHEMA_URI = (
27
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/"
28
+ "Schemata/sarif-schema-2.1.0.json"
29
+ )
30
+ TOOL_INFO_URI = "https://github.com/mlaify/AttackMap"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Severity + score mappings
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def _sarif_level(severity: str) -> str:
39
+ """SARIF `level` is one of `error`, `warning`, `note`, `none`."""
40
+ return {"high": "error", "medium": "warning", "low": "note"}.get(severity, "warning")
41
+
42
+
43
+ def _security_severity(severity: str, confidence: str) -> str:
44
+ """SARIF `securitySeverity` is a 0.0-10.0 string, mirroring CVSS.
45
+
46
+ We derive it heuristically from `Finding.severity × Finding.confidence`
47
+ so consumers that filter by CVSS band (a common GitHub Code Scanning
48
+ workflow) get a reasonable ordering out of the box.
49
+ """
50
+ weights = {"high": 9.0, "medium": 6.0, "low": 3.0}
51
+ multiplier = {"high": 1.0, "medium": 0.75, "low": 0.5}.get(confidence, 0.75)
52
+ value = weights.get(severity, 6.0) * multiplier
53
+ # Clamp to SARIF's 0.0-10.0 range and 1-decimal precision.
54
+ return f"{min(10.0, max(0.0, value)):.1f}"
55
+
56
+
57
+ def _slugify(text: str) -> str:
58
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
59
+ return slug or "finding"
60
+
61
+
62
+ SRCROOT = "%SRCROOT%"
63
+
64
+
65
+ def _sarif_uri(file_path: str) -> str:
66
+ """Repo-relative, forward-slashed, percent-encoded artifact URI (#230)."""
67
+ return quote(file_path.replace("\\", "/").lstrip("/"), safe="/@()[]+~-._")
68
+
69
+
70
+ def _sarif_location(file_path: str, line_num: int | None) -> dict[str, Any]:
71
+ physical: dict[str, Any] = {"artifactLocation": {"uri": _sarif_uri(file_path), "uriBaseId": SRCROOT}}
72
+ # No fabricated `startLine: 1` (#230): a location without a known line is
73
+ # a file-level location with no region.
74
+ if line_num is not None and line_num >= 1:
75
+ physical["region"] = {"startLine": line_num}
76
+ return {"physicalLocation": physical}
77
+
78
+
79
+ def _instances(finding: Finding) -> list[tuple[str, int | None, str | None]]:
80
+ """``(file, line, fingerprint)`` per instance: structured locations first
81
+ (with their #222 fingerprints), else the files cited in evidence."""
82
+ if finding.locations:
83
+ pairs = [(loc.file, loc.line, loc.fingerprint) for loc in finding.locations]
84
+ else:
85
+ pairs = [(f, l, None) for f, l in evidence_locations(finding.evidence)]
86
+ # A fingerprint makes an instance distinct even at a repeated (file, line):
87
+ # two resources without a line in one file are two results.
88
+ seen: set[tuple[str, int | None, str | None]] = set()
89
+ out: list[tuple[str, int | None, str | None]] = []
90
+ for file, line, fp in pairs:
91
+ norm = file.replace("\\", "/")
92
+ key = (norm, line, fp)
93
+ if key not in seen:
94
+ seen.add(key)
95
+ out.append((norm, line, fp))
96
+ return out
97
+
98
+
99
+ def _finding_locations(finding: Finding) -> list[dict[str, Any]]:
100
+ return [_sarif_location(file, line) for file, line, _ in _instances(finding)]
101
+
102
+
103
+ def _locations_from_evidence(evidence: list[str]) -> list[dict[str, Any]]:
104
+ return [_sarif_location(path, line) for path, line in evidence_locations(evidence)]
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # SARIF construction
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def _tool_version() -> str:
113
+ try:
114
+ return version("attackmap")
115
+ except PackageNotFoundError:
116
+ return "0.0.0-dev"
117
+
118
+
119
+ def _build_rules(findings: list[Finding]) -> list[dict[str, Any]]:
120
+ """One SARIF rule per unique finding title. Consumers use rule ids
121
+ to filter, ignore, or roll up findings; sharing the rule across
122
+ identical-title findings keeps the taxonomy stable."""
123
+ rules_by_id: dict[str, dict[str, Any]] = {}
124
+ for finding in findings:
125
+ rule_id = finding_rule_id(finding)
126
+ if rule_id in rules_by_id:
127
+ continue
128
+ title, severity, confidence, mitigation = finding.title, finding.severity, finding.confidence, finding.mitigation
129
+ # A severity-tiered rule has a finding per tier: describe the rule by
130
+ # its spec's default tier, not by whichever tier came first.
131
+ from .threat_model import tiered_rule_defaults
132
+
133
+ defaults = tiered_rule_defaults(rule_id)
134
+ if defaults is not None:
135
+ title, severity, mitigation = defaults
136
+ confidence = "high"
137
+ rules_by_id[rule_id] = {
138
+ "id": rule_id,
139
+ "name": title,
140
+ "shortDescription": {"text": title},
141
+ "fullDescription": {"text": title},
142
+ "help": {"text": mitigation, "markdown": mitigation},
143
+ "defaultConfiguration": {"level": _sarif_level(severity)},
144
+ "properties": {
145
+ "tags": list(finding.tags),
146
+ "security-severity": _security_severity(severity, confidence),
147
+ },
148
+ }
149
+ _add_rule_taxonomy(rules_by_id[rule_id], rule_id)
150
+ return list(rules_by_id.values())
151
+
152
+
153
+ CWE_TAXONOMY = "CWE"
154
+
155
+
156
+ def _add_rule_taxonomy(rule: dict[str, Any], rule_id: str) -> None:
157
+ """CWE tags, helpUri, CWE relationships and the OWASP/ASVS/ATT&CK ids
158
+ from the taxonomy registry (#250). Unregistered rules are left as is."""
159
+ entry = taxonomy_for(rule_id)
160
+ if entry is None:
161
+ return
162
+ props = rule["properties"]
163
+ # Detectors may already tag the CWE (workflow findings do); keep one copy.
164
+ props["tags"] = list(dict.fromkeys([*props["tags"], *(cwe_tag(c) for c in entry.cwe)]))
165
+ props["cwe"] = entry.cwe_ids
166
+ props["owasp"] = list(entry.owasp)
167
+ if entry.asvs:
168
+ props["asvs"] = list(entry.asvs)
169
+ if entry.attack:
170
+ props["attack"] = list(entry.attack)
171
+ rule["helpUri"] = cwe_url(entry.cwe[0])
172
+ rule["relationships"] = [
173
+ {"target": {"id": str(c), "toolComponent": {"name": CWE_TAXONOMY}}, "kinds": ["superset"]}
174
+ for c in entry.cwe
175
+ ]
176
+
177
+
178
+ def _cwe_taxonomy(rules: list[dict[str, Any]]) -> dict[str, Any] | None:
179
+ """The run's CWE `taxonomies` entry: one taxon per CWE a rule cites."""
180
+ ids = sorted(
181
+ {int(rel["target"]["id"]) for rule in rules for rel in rule.get("relationships", [])}
182
+ )
183
+ if not ids:
184
+ return None
185
+ return {
186
+ "name": CWE_TAXONOMY,
187
+ "organization": "MITRE",
188
+ "informationUri": "https://cwe.mitre.org/",
189
+ "shortDescription": {"text": "The MITRE Common Weakness Enumeration"},
190
+ "taxa": [
191
+ {
192
+ "id": str(c),
193
+ "name": CWE_NAMES.get(c, f"CWE-{c}"),
194
+ "shortDescription": {"text": CWE_NAMES.get(c, f"CWE-{c}")},
195
+ "helpUri": cwe_url(c),
196
+ }
197
+ for c in ids
198
+ ],
199
+ }
200
+
201
+
202
+ def _stable_hash(*parts: object) -> str:
203
+ return hashlib.sha256("\x1f".join(str(p) for p in parts).encode("utf-8")).hexdigest()[:32]
204
+
205
+
206
+ def _code_flow(steps: list[TaintFlowStep]) -> dict[str, Any]:
207
+ """A traced source→sink flow (#239) as a SARIF ``codeFlow``: one
208
+ threadFlow whose locations are the source, each propagation /
209
+ sanitizer / guard step, and the sink."""
210
+ locations = []
211
+ for step in steps:
212
+ loc = _sarif_location(step.file, step.line)
213
+ loc["message"] = {"text": f"{step.kind}: {step.evidence_text or ''}".rstrip(": ")}
214
+ locations.append({"location": loc, "kinds": [step.kind]})
215
+ source = steps[0] if steps else None
216
+ text = f"{source.evidence_text} flows to the sink" if source and source.evidence_text else "taint flow"
217
+ return {"message": {"text": text}, "threadFlows": [{"locations": locations}]}
218
+
219
+
220
+ def _flows_by_sink(finding: Finding) -> dict[tuple[str, int | None], list[dict[str, Any]]]:
221
+ out: dict[tuple[str, int | None], list[dict[str, Any]]] = {}
222
+ for steps in finding.code_flows:
223
+ if not steps:
224
+ continue
225
+ sink = steps[-1]
226
+ out.setdefault((sink.file.replace("\\", "/"), sink.line), []).append(_code_flow(steps))
227
+ return out
228
+
229
+
230
+ def _build_result(finding: Finding, *, suppression_reason: str | None = None) -> list[dict[str, Any]]:
231
+ """One SARIF result per finding instance (#230).
232
+
233
+ Each result carries a single primary location and a partial fingerprint
234
+ derived from (rule id, instance fingerprint) — never evidence text — so
235
+ Code Scanning keeps alert identity (and dismissals) stable when another
236
+ instance appears or a snippet changes. Evidence lives in properties.
237
+ """
238
+ rule_id = finding_rule_id(finding)
239
+ properties: dict[str, Any] = {
240
+ "tags": list(finding.tags),
241
+ "security-severity": _security_severity(finding.severity, finding.confidence),
242
+ "confidence": finding.confidence,
243
+ }
244
+ if finding.score is not None:
245
+ # SARIF `rank` is 0-100, higher = more important — same
246
+ # direction as `score` from #4.
247
+ properties["rank"] = float(finding.score)
248
+ if finding.evidence:
249
+ properties["evidence"] = list(finding.evidence[:8])
250
+
251
+ def result(location: dict[str, Any] | None, fingerprint: str) -> dict[str, Any]:
252
+ r: dict[str, Any] = {
253
+ "ruleId": rule_id,
254
+ "level": _sarif_level(finding.severity),
255
+ "message": {
256
+ "text": finding.title,
257
+ "markdown": f"**{md_text(finding.title)}**\n\n{finding.mitigation}",
258
+ },
259
+ "partialFingerprints": {"attackmapInstance/v1": fingerprint},
260
+ "properties": properties,
261
+ }
262
+ if location is not None:
263
+ r["locations"] = [location]
264
+ # Suppressed findings stay in the log (not dropped) but carry a SARIF
265
+ # `suppressions` array so viewers / GitHub Code Scanning show them as
266
+ # suppressed rather than active (#144).
267
+ if suppression_reason is not None:
268
+ r["suppressions"] = [
269
+ {"kind": "external", "justification": suppression_reason or "suppressed by AttackMap"}
270
+ ]
271
+ return r
272
+
273
+ instances = _instances(finding)
274
+ if not instances:
275
+ # Repo-level finding (no citable file): identity is the rule + title.
276
+ return [result(None, _stable_hash(rule_id, finding.title))]
277
+ results = [
278
+ result(_sarif_location(file, line), _stable_hash(rule_id, fp or f"{file}:{line}"))
279
+ for file, line, fp in instances
280
+ ]
281
+ # Traced taint flows (#239) ride on the result at their sink location.
282
+ flows = _flows_by_sink(finding)
283
+ if flows:
284
+ for r, (file, line, _) in zip(results, instances):
285
+ matched = flows.pop((file, line), None)
286
+ if matched:
287
+ r["codeFlows"] = matched
288
+ return results
289
+
290
+
291
+ def _build_results(findings: list[Finding]) -> list[dict[str, Any]]:
292
+ return [r for finding in findings for r in _build_result(finding)]
293
+
294
+
295
+ def _build_code_flows_from_attack_paths(
296
+ attack_paths: list[AttackPath],
297
+ ) -> list[dict[str, Any]]:
298
+ """Turn each attack path into a SARIF `codeFlow`. This gives the
299
+ narrative a first-class home separate from findings — SARIF viewers
300
+ render codeFlows as expandable step lists.
301
+
302
+ Codeflows aren't attached to a specific result here (attack paths
303
+ are repo-scoped, not per-finding); consumers that want them
304
+ inline can traverse the tool.driver.properties.attackPaths link.
305
+ """
306
+ flows: list[dict[str, Any]] = []
307
+ for path in attack_paths:
308
+ # Steps are narrative, not code positions: a message-only location
309
+ # (no fabricated empty URI / line 1, #230).
310
+ thread_flow_locations = [{"location": {"message": {"text": step}}} for step in path.steps]
311
+ flows.append(
312
+ {
313
+ "message": {"text": f"{path.name}: {path.impact}"},
314
+ "threadFlows": [{"locations": thread_flow_locations}],
315
+ }
316
+ )
317
+ return flows
318
+
319
+
320
+ def build_sarif(
321
+ findings: list[Finding],
322
+ attack_paths: list[AttackPath] | None = None,
323
+ *,
324
+ suppressed: list[tuple[Finding, str]] | None = None,
325
+ ) -> dict[str, Any]:
326
+ """Serialize `findings` (+ optional `attack_paths`) as a SARIF 2.1.0
327
+ log dict. Caller is responsible for writing it to disk.
328
+
329
+ `suppressed` is a list of ``(finding, justification)`` pairs (#144):
330
+ they are emitted as results carrying a SARIF ``suppressions`` array so
331
+ consumers show them as suppressed rather than active.
332
+ """
333
+ suppressed = suppressed or []
334
+ # Rules taxonomy must cover suppressed findings too, so their ruleId
335
+ # resolves in viewers.
336
+ rules = _build_rules(findings + [f for f, _ in suppressed])
337
+ results = _build_results(findings)
338
+ for f, reason in suppressed:
339
+ results.extend(_build_result(f, suppression_reason=reason))
340
+
341
+ driver: dict[str, Any] = {
342
+ "name": "AttackMap",
343
+ "version": _tool_version(),
344
+ "informationUri": TOOL_INFO_URI,
345
+ "rules": rules,
346
+ }
347
+ cwe_taxonomy = _cwe_taxonomy(rules)
348
+ if cwe_taxonomy is not None:
349
+ driver["supportedTaxonomies"] = [{"name": CWE_TAXONOMY}]
350
+ run: dict[str, Any] = {
351
+ "tool": {"driver": driver},
352
+ # Artifact URIs are relative to the scanned repo root; the absolute
353
+ # local path is deliberately not embedded.
354
+ "originalUriBaseIds": {SRCROOT: {"description": {"text": "Root of the scanned repository."}}},
355
+ "results": results,
356
+ }
357
+ if cwe_taxonomy is not None:
358
+ run["taxonomies"] = [cwe_taxonomy]
359
+ if attack_paths:
360
+ # Attach codeflows at the run level via a properties bag. SARIF
361
+ # allows tool-defined properties here; consumers who care about
362
+ # attack paths can traverse `run.properties.attackPaths`.
363
+ run["properties"] = {
364
+ "attackPaths": _build_code_flows_from_attack_paths(attack_paths),
365
+ }
366
+ return {
367
+ "$schema": SARIF_SCHEMA_URI,
368
+ "version": SARIF_VERSION,
369
+ "runs": [run],
370
+ }
371
+
372
+
373
+ __all__ = ["build_sarif", "SARIF_VERSION"]