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