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/suppress.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""Finding suppression: inline comments + a repo-level baseline file (#144).
|
|
2
|
+
|
|
3
|
+
Teams adopting AttackMap in CI need a way to silence a residual false
|
|
4
|
+
positive *without* going blind to new signal. This module provides two
|
|
5
|
+
mechanisms, both applied as a post-pass over the assembled ``Finding``
|
|
6
|
+
list:
|
|
7
|
+
|
|
8
|
+
## 1. Repo-level baseline — ``.attackmap-suppress.yaml``
|
|
9
|
+
|
|
10
|
+
Placed at the repo root. Each entry carries a mandatory ``reason`` and one
|
|
11
|
+
or more selectors:
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
version: 1
|
|
15
|
+
suppress:
|
|
16
|
+
# by stable finding id (the 16-hex id in attackmap-report.json)
|
|
17
|
+
- id: 1a2b3c4d5e6f7a8b
|
|
18
|
+
reason: accepted risk, tracked in JIRA-1234
|
|
19
|
+
|
|
20
|
+
# by rule id (the stable detector id — `attackmap rules` lists them; it is
|
|
21
|
+
# also the SARIF ruleId)
|
|
22
|
+
- rule: hardcoded-secret
|
|
23
|
+
reason: test fixtures only, never shipped
|
|
24
|
+
paths: ["tests/fixtures/**"] # optional: scope the rule to paths
|
|
25
|
+
expires: 2026-12-31 # optional (#238): not applied after this
|
|
26
|
+
owner: platform-team # optional: who accepted the risk
|
|
27
|
+
ticket: https://tracker.example/SEC-123
|
|
28
|
+
|
|
29
|
+
# by path only (any finding whose evidence is *entirely* within paths)
|
|
30
|
+
- path: "vendor/**"
|
|
31
|
+
reason: third-party code, out of scope
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
``paths``/``path`` are globs: ``*`` matches within one directory and ``**``
|
|
35
|
+
across directories (``vendor/**`` covers the subtree, ``vendor/*`` only its
|
|
36
|
+
direct files). A glob that matches everything (``*``, ``**``, ``**/*``) is
|
|
37
|
+
rejected. A path selector suppresses only the finding instances it covers —
|
|
38
|
+
other instances of the same finding stay active.
|
|
39
|
+
|
|
40
|
+
Entries past ``expires`` are not applied and are warned about
|
|
41
|
+
(``--strict-suppressions`` makes that exit 2). With
|
|
42
|
+
``--suppress-from-ref <ref>`` (the GitHub Action passes the PR's base branch),
|
|
43
|
+
only entries that already exist at ``<ref>`` apply; ones added since are
|
|
44
|
+
reported as *pending* (``--allow-pr-suppressions`` applies them anyway). An
|
|
45
|
+
inline directive that exists at ``<ref>`` doesn't cover the line below it if
|
|
46
|
+
that line was added or changed since: a PR can't place new code under an
|
|
47
|
+
existing directive.
|
|
48
|
+
|
|
49
|
+
## 2. Inline directives
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
API_KEY = "…" # attackmap:ignore[hardcoded-secret] rotated, staging only
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A directive covers only the finding **instance on its own line or the line
|
|
56
|
+
below it** (#224) — put it at the end of the flagged line, or on its own line
|
|
57
|
+
just above. Other findings in the same file, and instances added later, stay
|
|
58
|
+
active. A finding with several instances is suppressed when every instance is
|
|
59
|
+
covered; partly covered findings stay active with the covered instances
|
|
60
|
+
removed. Name the rule (``attackmap rules`` lists ids); a bare
|
|
61
|
+
``attackmap:ignore`` matches any rule on that line and is warned about.
|
|
62
|
+
|
|
63
|
+
Inline directives accept an expiry in their reason:
|
|
64
|
+
``# attackmap:ignore[weak-password-hash] legacy hash until=2026-12-31``.
|
|
65
|
+
|
|
66
|
+
For file-wide scope use the explicit form:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# attackmap:ignore-file[hardcoded-secret] fixture keys, never deployed
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Semantics
|
|
73
|
+
|
|
74
|
+
Suppressed findings are **not dropped** — they are partitioned out of the
|
|
75
|
+
active set (so they don't trip ``--fail-on-new-high`` and don't clutter the
|
|
76
|
+
console), but they are still emitted to SARIF marked ``suppressions`` and to
|
|
77
|
+
``attackmap-report.json`` under ``suppressed_findings``, each carrying the
|
|
78
|
+
reason. Suppression counts are printed in the run summary.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
from datetime import date, datetime
|
|
84
|
+
|
|
85
|
+
import re
|
|
86
|
+
from dataclasses import dataclass, field, replace
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
|
|
89
|
+
from .srcpaths import evidence_locations, split_lines
|
|
90
|
+
from .gitref import added_lines, show_file, validate_ref
|
|
91
|
+
from .safe_fs import contained_file, read_repo_text
|
|
92
|
+
from .diff import finding_id
|
|
93
|
+
from .models import Finding, finding_rule_id, title_slug
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"Suppression",
|
|
97
|
+
"SuppressionSet",
|
|
98
|
+
"SuppressedFinding",
|
|
99
|
+
"SuppressionOutcome",
|
|
100
|
+
"collect_suppressions",
|
|
101
|
+
"apply_suppressions",
|
|
102
|
+
"rule_slug",
|
|
103
|
+
"SUPPRESS_FILENAMES",
|
|
104
|
+
"INLINE_DIRECTIVE",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# Candidate baseline filenames at the repo root, in preference order.
|
|
108
|
+
SUPPRESS_FILENAMES = (".attackmap-suppress.yaml", ".attackmap-suppress.yml")
|
|
109
|
+
|
|
110
|
+
# `# attackmap:ignore[rule-a, rule-b] free-text reason`. The comment leader
|
|
111
|
+
# (`#`, `//`, `--`, `;`, `/* … */`) is irrelevant — we only match the
|
|
112
|
+
# directive token, so it works across languages. Rule list is optional; a
|
|
113
|
+
# bare `attackmap:ignore` matches any rule.
|
|
114
|
+
INLINE_DIRECTIVE = re.compile(
|
|
115
|
+
r"attackmap:ignore(?:-file)?(?:\[\s*([a-z0-9_.,\s-]*?)\s*\])?[ \t:-]*(.*?)\s*(?:\*/)?$",
|
|
116
|
+
re.IGNORECASE,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Directories we never read when scanning for inline directives — matches the
|
|
120
|
+
# scanner's own output dirs so we never chase our own reports (see #85 / the
|
|
121
|
+
# 0.4.5 feedback-loop fix).
|
|
122
|
+
_SKIP_INLINE_PARTS = {".git", ".attackmap", ".attackmap-gui", "node_modules"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rule_slug(title: str) -> str:
|
|
126
|
+
"""Human-readable, stable rule id for a finding title.
|
|
127
|
+
|
|
128
|
+
Identical to ``sarif._slugify`` so the ``rule:`` key in a suppress file
|
|
129
|
+
matches the ``ruleId`` a user sees in SARIF / GitHub Code Scanning. The
|
|
130
|
+
equality is locked by a test.
|
|
131
|
+
"""
|
|
132
|
+
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
133
|
+
return slug or "finding"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _instances(finding: Finding) -> list[tuple[str, int | None]]:
|
|
137
|
+
"""``(file, line)`` per instance: structured locations (#214), else the
|
|
138
|
+
locations cited in evidence."""
|
|
139
|
+
if finding.locations:
|
|
140
|
+
return [(loc.file.replace("\\", "/"), loc.line) for loc in finding.locations]
|
|
141
|
+
return [(f.replace("\\", "/"), l) for f, l in evidence_locations(finding.evidence)]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _evidence_paths(finding: Finding) -> list[str]:
|
|
145
|
+
"""Source files a finding covers: structured `Finding.locations` (#214),
|
|
146
|
+
else the files cited in its evidence (shared parser, #213)."""
|
|
147
|
+
pairs = (
|
|
148
|
+
[(loc.file, loc.line) for loc in finding.locations]
|
|
149
|
+
if finding.locations
|
|
150
|
+
else evidence_locations(finding.evidence)
|
|
151
|
+
)
|
|
152
|
+
paths: list[str] = []
|
|
153
|
+
for file, _line in pairs:
|
|
154
|
+
path = file.replace("\\", "/")
|
|
155
|
+
if path not in paths:
|
|
156
|
+
paths.append(path)
|
|
157
|
+
return paths
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _glob_regex(glob: str) -> re.Pattern[str]:
|
|
161
|
+
"""``*`` matches within one path segment, ``**`` across segments (#238),
|
|
162
|
+
``?`` one non-separator character."""
|
|
163
|
+
out: list[str] = []
|
|
164
|
+
i = 0
|
|
165
|
+
while i < len(glob):
|
|
166
|
+
if glob.startswith("**/", i):
|
|
167
|
+
out.append("(?:.*/)?")
|
|
168
|
+
i += 3
|
|
169
|
+
elif glob.startswith("**", i):
|
|
170
|
+
out.append(".*")
|
|
171
|
+
i += 2
|
|
172
|
+
elif glob[i] == "*":
|
|
173
|
+
out.append("[^/]*")
|
|
174
|
+
i += 1
|
|
175
|
+
elif glob[i] == "?":
|
|
176
|
+
out.append("[^/]")
|
|
177
|
+
i += 1
|
|
178
|
+
else:
|
|
179
|
+
out.append(re.escape(glob[i]))
|
|
180
|
+
i += 1
|
|
181
|
+
return re.compile("".join(out))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _glob_match(path: str, glob: str) -> bool:
|
|
185
|
+
return _glob_regex(glob.strip().lstrip("./")).fullmatch(path.lstrip("./")) is not None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_match_everything_glob(glob: str) -> bool:
|
|
189
|
+
"""`*`, `**`, `**/*`, `/` … — a glob that would silence the whole repo."""
|
|
190
|
+
return not glob.strip().strip("/").replace("*", "").replace("/", "").replace(".", "")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class Suppression:
|
|
195
|
+
"""One suppression selector plus its justification.
|
|
196
|
+
|
|
197
|
+
Exactly one *primary* selector kind is used per instance: ``id`` (exact
|
|
198
|
+
finding), ``rule`` without ``path`` (whole rule), or ``path`` (with an
|
|
199
|
+
optional ``rule`` scope). ``origin`` and ``source`` are for logging.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
reason: str
|
|
203
|
+
id: str | None = None
|
|
204
|
+
rule: str | None = None
|
|
205
|
+
path: str | None = None
|
|
206
|
+
# Inline directives (#224) cover only the instance on their own line or
|
|
207
|
+
# the line below; `attackmap:ignore-file` (and suppress-file `path:`
|
|
208
|
+
# entries) leave this None and cover the whole file.
|
|
209
|
+
line: int | None = None
|
|
210
|
+
# Lines this directive must not cover: lines added since the trusted ref
|
|
211
|
+
# (#238). A directive already on the base branch covers the line below
|
|
212
|
+
# it, but a PR that adds or changes that line must not inherit the
|
|
213
|
+
# suppression.
|
|
214
|
+
untrusted_lines: frozenset[int] = frozenset()
|
|
215
|
+
# Governance metadata (#238). An entry past `expires` is not applied.
|
|
216
|
+
expires: date | None = None
|
|
217
|
+
owner: str | None = None
|
|
218
|
+
ticket: str | None = None
|
|
219
|
+
origin: str = "file" # "file" | "inline"
|
|
220
|
+
source: str = "" # human-readable declaration site
|
|
221
|
+
|
|
222
|
+
def label(self) -> str:
|
|
223
|
+
where = f"{self.path}:{self.line}" if self.line is not None else self.path
|
|
224
|
+
if self.id:
|
|
225
|
+
sel = f"id={self.id}"
|
|
226
|
+
elif self.path and self.rule:
|
|
227
|
+
sel = f"rule={self.rule} path={where}"
|
|
228
|
+
elif self.path:
|
|
229
|
+
sel = f"path={where}"
|
|
230
|
+
else:
|
|
231
|
+
sel = f"rule={self.rule}"
|
|
232
|
+
return f"{sel} ({self.origin})"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class SuppressedFinding:
|
|
237
|
+
"""A finding that matched at least one suppression."""
|
|
238
|
+
|
|
239
|
+
finding: Finding
|
|
240
|
+
id: str
|
|
241
|
+
rule: str
|
|
242
|
+
reason: str
|
|
243
|
+
matched: list[Suppression] = field(default_factory=list)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class SuppressionOutcome:
|
|
248
|
+
active: list[Finding]
|
|
249
|
+
suppressed: list[SuppressedFinding]
|
|
250
|
+
warnings: list[str] = field(default_factory=list)
|
|
251
|
+
# Post-match notices (#223): legacy selectors and entries matching nothing.
|
|
252
|
+
notices: list[str] = field(default_factory=list)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def count(self) -> int:
|
|
256
|
+
return len(self.suppressed)
|
|
257
|
+
|
|
258
|
+
def summary_lines(self) -> list[str]:
|
|
259
|
+
"""One line per suppressed finding, for the run summary."""
|
|
260
|
+
lines: list[str] = []
|
|
261
|
+
for s in self.suppressed:
|
|
262
|
+
reasons = "; ".join(dict.fromkeys(m.reason for m in s.matched if m.reason))
|
|
263
|
+
lines.append(f" - [{s.rule}] {s.finding.title} — {reasons or 'no reason given'}")
|
|
264
|
+
return lines
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class SuppressionSet:
|
|
268
|
+
"""Resolves whether a finding is suppressed and by which selector(s)."""
|
|
269
|
+
|
|
270
|
+
def __init__(self, suppressions: list[Suppression]) -> None:
|
|
271
|
+
self._all = list(suppressions)
|
|
272
|
+
# Governance (#238), filled by collect_suppressions.
|
|
273
|
+
self.pending: list[Suppression] = []
|
|
274
|
+
self.pending_applied = False
|
|
275
|
+
self.expired: list[Suppression] = []
|
|
276
|
+
self._used: set[int] = set()
|
|
277
|
+
# Legacy title-slug selectors that matched, keyed by selector →
|
|
278
|
+
# the stable rule id to use instead (#223).
|
|
279
|
+
self.legacy_selectors: dict[str, str] = {}
|
|
280
|
+
self._by_id: dict[str, list[Suppression]] = {}
|
|
281
|
+
self._rule_only: list[Suppression] = [] # rule set, no path → whole-rule
|
|
282
|
+
self._path: list[Suppression] = [] # path set (optional rule scope)
|
|
283
|
+
for sup in suppressions:
|
|
284
|
+
if sup.id:
|
|
285
|
+
self._by_id.setdefault(sup.id, []).append(sup)
|
|
286
|
+
elif sup.path:
|
|
287
|
+
self._path.append(sup)
|
|
288
|
+
elif sup.rule:
|
|
289
|
+
self._rule_only.append(sup)
|
|
290
|
+
|
|
291
|
+
def match(self, finding: Finding) -> list[Suppression]:
|
|
292
|
+
"""Return the suppressions that cover ``finding`` (empty = active)."""
|
|
293
|
+
hits = self._match(finding)
|
|
294
|
+
self._used.update(id(s) for s in hits)
|
|
295
|
+
return hits
|
|
296
|
+
|
|
297
|
+
def mark_used(self, sups) -> None: # type: ignore[no-untyped-def]
|
|
298
|
+
self._used.update(id(s) for s in sups)
|
|
299
|
+
|
|
300
|
+
def unused(self) -> list[Suppression]:
|
|
301
|
+
"""Entries that matched no finding in this run (#223)."""
|
|
302
|
+
return [s for s in self._all if id(s) not in self._used]
|
|
303
|
+
|
|
304
|
+
def _rule_ok(self, selector_rule: str | None, finding: Finding) -> bool:
|
|
305
|
+
"""A ``None`` selector is a wildcard. Otherwise it must equal the stable
|
|
306
|
+
rule id, or — deprecated — the finding's title slug."""
|
|
307
|
+
if selector_rule is None:
|
|
308
|
+
return True
|
|
309
|
+
selector = rule_slug(selector_rule)
|
|
310
|
+
stable = finding_rule_id(finding)
|
|
311
|
+
if selector == stable:
|
|
312
|
+
return True
|
|
313
|
+
legacy = title_slug(finding.title)
|
|
314
|
+
if finding.rule_id and selector == legacy:
|
|
315
|
+
self.legacy_selectors[selector] = stable
|
|
316
|
+
return True
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
def _match(self, finding: Finding) -> list[Suppression]:
|
|
320
|
+
fid = finding_id(finding.title)
|
|
321
|
+
|
|
322
|
+
exact = self._by_id.get(fid, [])
|
|
323
|
+
if exact:
|
|
324
|
+
return list(exact)
|
|
325
|
+
|
|
326
|
+
rule_hits = [s for s in self._rule_only if self._rule_ok(s.rule, finding)]
|
|
327
|
+
if rule_hits:
|
|
328
|
+
return rule_hits
|
|
329
|
+
|
|
330
|
+
# Path coverage: the finding is suppressed only if EVERY instance it
|
|
331
|
+
# covers is covered — by a path glob / `ignore-file` directive for its
|
|
332
|
+
# file, or by a line-scoped inline directive on its line (or the line
|
|
333
|
+
# above it, #224). A finding with no citable location can never be
|
|
334
|
+
# path-suppressed (only by id/rule).
|
|
335
|
+
coverage = self.instance_coverage(finding)
|
|
336
|
+
if not coverage or any(c is None for c in coverage):
|
|
337
|
+
return []
|
|
338
|
+
return list(dict.fromkeys(coverage)) # type: ignore[arg-type]
|
|
339
|
+
|
|
340
|
+
def instance_coverage(self, finding: Finding) -> list[Suppression | None]:
|
|
341
|
+
"""Per instance of ``finding``, the path/inline suppression covering
|
|
342
|
+
it (or None). Empty when the finding has no citable location."""
|
|
343
|
+
candidates = [s for s in self._path if self._rule_ok(s.rule, finding)]
|
|
344
|
+
instances = _instances(finding)
|
|
345
|
+
if not candidates or not instances:
|
|
346
|
+
return [None] * len(instances)
|
|
347
|
+
out: list[Suppression | None] = []
|
|
348
|
+
for file, line in instances:
|
|
349
|
+
covering = None
|
|
350
|
+
for sup in candidates:
|
|
351
|
+
if sup.line is not None:
|
|
352
|
+
if (
|
|
353
|
+
file == sup.path
|
|
354
|
+
and line is not None
|
|
355
|
+
and line in (sup.line, sup.line + 1)
|
|
356
|
+
and line not in sup.untrusted_lines
|
|
357
|
+
):
|
|
358
|
+
covering = sup
|
|
359
|
+
break
|
|
360
|
+
elif _glob_match(file, sup.path or ""):
|
|
361
|
+
covering = sup
|
|
362
|
+
break
|
|
363
|
+
out.append(covering)
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def apply_suppressions(
|
|
368
|
+
findings: list[Finding], suppset: SuppressionSet, *, warnings: list[str] | None = None
|
|
369
|
+
) -> SuppressionOutcome:
|
|
370
|
+
active: list[Finding] = []
|
|
371
|
+
suppressed: list[SuppressedFinding] = []
|
|
372
|
+
for finding in findings:
|
|
373
|
+
matched = suppset.match(finding)
|
|
374
|
+
if not matched:
|
|
375
|
+
# Instance-level inline directives (#224): drop just the covered
|
|
376
|
+
# locations and keep the finding active for the rest.
|
|
377
|
+
coverage = suppset.instance_coverage(finding)
|
|
378
|
+
if finding.locations and len(coverage) == len(finding.locations) and any(coverage):
|
|
379
|
+
suppset.mark_used(c for c in coverage if c is not None)
|
|
380
|
+
finding = finding.model_copy(
|
|
381
|
+
update={"locations": [loc for loc, c in zip(finding.locations, coverage) if c is None]}
|
|
382
|
+
)
|
|
383
|
+
if matched:
|
|
384
|
+
suppressed.append(
|
|
385
|
+
SuppressedFinding(
|
|
386
|
+
finding=finding,
|
|
387
|
+
id=finding_id(finding.title),
|
|
388
|
+
rule=finding_rule_id(finding),
|
|
389
|
+
reason="; ".join(dict.fromkeys(m.reason for m in matched if m.reason)),
|
|
390
|
+
matched=matched,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
active.append(finding)
|
|
395
|
+
notices: list[str] = []
|
|
396
|
+
for selector, stable in sorted(suppset.legacy_selectors.items()):
|
|
397
|
+
notices.append(
|
|
398
|
+
f"rule '{selector}' is a legacy title-based selector; use the stable rule id "
|
|
399
|
+
f"'{stable}' (title selectors will stop working in a future release)"
|
|
400
|
+
)
|
|
401
|
+
for sup in suppset.unused():
|
|
402
|
+
where = f" at {sup.source}" if sup.source else ""
|
|
403
|
+
notices.append(f"suppression {sup.label()}{where} matched no findings")
|
|
404
|
+
return SuppressionOutcome(
|
|
405
|
+
active=active, suppressed=suppressed, warnings=warnings or [], notices=notices
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
# Loading
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_FINDING_ID_RE = re.compile(r"^[0-9a-f]{16}$")
|
|
415
|
+
_SUPPORTED_VERSIONS = {1}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _str_list(value: object) -> list[str] | None:
|
|
419
|
+
"""A ``paths`` value as a list of strings, or None when malformed."""
|
|
420
|
+
if isinstance(value, str):
|
|
421
|
+
return [value]
|
|
422
|
+
if isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
423
|
+
return list(value)
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _load_yaml_entries(path: Path, warnings: list[str], *, root: Path | None = None) -> list[Suppression]:
|
|
428
|
+
"""Parse a suppress file. Never raises (#225): every problem becomes a
|
|
429
|
+
``Suppression warning`` and the offending entry (or file) is skipped."""
|
|
430
|
+
try:
|
|
431
|
+
# Availability check only; parse_suppress_text does the import it uses.
|
|
432
|
+
import yaml # type: ignore[import-untyped] # noqa: F401
|
|
433
|
+
except ImportError:
|
|
434
|
+
warnings.append(
|
|
435
|
+
f"{path.name} found but PyYAML is not installed; suppressions ignored. "
|
|
436
|
+
"Install attackmap with its dependencies to enable suppression."
|
|
437
|
+
)
|
|
438
|
+
return []
|
|
439
|
+
try:
|
|
440
|
+
# A repo-provided suppress file is read like any repo file: never
|
|
441
|
+
# through a symlink out of the repo (#234).
|
|
442
|
+
text = read_repo_text(root, path) if root is not None else path.read_text(encoding="utf-8")
|
|
443
|
+
except UnicodeDecodeError:
|
|
444
|
+
warnings.append(f"{path.name} is not valid UTF-8; suppressions ignored")
|
|
445
|
+
return []
|
|
446
|
+
except OSError as exc:
|
|
447
|
+
warnings.append(f"Failed to parse {path.name}: {exc}")
|
|
448
|
+
return []
|
|
449
|
+
return parse_suppress_text(text, path.name, warnings)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def parse_suppress_text(text: str, name: str, warnings: list[str]) -> list[Suppression]:
|
|
453
|
+
"""Parse suppress-file content (from disk or from a git ref)."""
|
|
454
|
+
import yaml # type: ignore[import-untyped]
|
|
455
|
+
|
|
456
|
+
path = Path(name)
|
|
457
|
+
try:
|
|
458
|
+
data = yaml.safe_load(text)
|
|
459
|
+
except yaml.YAMLError as exc: # type: ignore[attr-defined]
|
|
460
|
+
warnings.append(f"Failed to parse {path.name}: {exc}")
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
if isinstance(data, dict):
|
|
464
|
+
version = data.get("version", 1)
|
|
465
|
+
if version not in _SUPPORTED_VERSIONS:
|
|
466
|
+
warnings.append(f"{path.name}: unknown version {version!r} (supported: 1); reading it as version 1")
|
|
467
|
+
entries = data.get("suppress", data.get("suppressions", []))
|
|
468
|
+
if entries is None:
|
|
469
|
+
entries = []
|
|
470
|
+
elif isinstance(data, list):
|
|
471
|
+
entries = data
|
|
472
|
+
elif data is None:
|
|
473
|
+
entries = []
|
|
474
|
+
else:
|
|
475
|
+
warnings.append(f"{path.name}: expected a list or a mapping with 'suppress:'")
|
|
476
|
+
return []
|
|
477
|
+
if not isinstance(entries, list):
|
|
478
|
+
warnings.append(f"{path.name}: 'suppress' must be a list of entries, got {type(entries).__name__}")
|
|
479
|
+
return []
|
|
480
|
+
|
|
481
|
+
suppressions: list[Suppression] = []
|
|
482
|
+
for index, raw in enumerate(entries, start=1):
|
|
483
|
+
where = f"{path.name} entry {index}"
|
|
484
|
+
if not isinstance(raw, dict):
|
|
485
|
+
warnings.append(f"{where}: skipping non-mapping entry {raw!r}")
|
|
486
|
+
continue
|
|
487
|
+
reason = raw.get("reason", "")
|
|
488
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
489
|
+
warnings.append(f"{where}: skipping entry without a text 'reason'")
|
|
490
|
+
continue
|
|
491
|
+
reason = reason.strip()
|
|
492
|
+
|
|
493
|
+
sup_id = raw.get("id")
|
|
494
|
+
if sup_id is not None:
|
|
495
|
+
if not isinstance(sup_id, str):
|
|
496
|
+
warnings.append(
|
|
497
|
+
f"{where}: id {sup_id!r} was read as {type(sup_id).__name__}, not text — quote it "
|
|
498
|
+
"(e.g. id: \"0123456789abcdef\"); YAML turns digit-only or 0-prefixed ids into numbers"
|
|
499
|
+
)
|
|
500
|
+
continue
|
|
501
|
+
if not _FINDING_ID_RE.match(sup_id.strip()):
|
|
502
|
+
warnings.append(f"{where}: id {sup_id!r} is not a 16-hex finding id; skipping")
|
|
503
|
+
continue
|
|
504
|
+
sup_id = sup_id.strip()
|
|
505
|
+
|
|
506
|
+
rule = raw.get("rule")
|
|
507
|
+
if rule is not None and (not isinstance(rule, str) or not rule.strip()):
|
|
508
|
+
warnings.append(f"{where}: 'rule' must be a rule id string (see `attackmap rules`); skipping")
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
globs = raw.get("paths", raw.get("path"))
|
|
512
|
+
glob_list: list[str] = []
|
|
513
|
+
if globs is not None:
|
|
514
|
+
parsed = _str_list(globs)
|
|
515
|
+
if parsed is None:
|
|
516
|
+
warnings.append(f"{where}: 'paths' must be a string or a list of strings, got {globs!r}; skipping")
|
|
517
|
+
continue
|
|
518
|
+
glob_list = [g for g in parsed if g.strip()]
|
|
519
|
+
broad = [g for g in glob_list if is_match_everything_glob(g)]
|
|
520
|
+
if broad:
|
|
521
|
+
warnings.append(
|
|
522
|
+
f"{where}: path {broad[0]!r} would suppress every file in the repo; rejected — "
|
|
523
|
+
"scope it (e.g. 'tests/fixtures/**') or use a 'rule:' entry"
|
|
524
|
+
)
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
expires = _parse_date(raw.get("expires"))
|
|
528
|
+
if raw.get("expires") is not None and expires is None:
|
|
529
|
+
warnings.append(f"{where}: 'expires' must be a YYYY-MM-DD date; skipping")
|
|
530
|
+
continue
|
|
531
|
+
owner = raw.get("owner") if isinstance(raw.get("owner"), str) else None
|
|
532
|
+
ticket = raw.get("ticket") if isinstance(raw.get("ticket"), str) else None
|
|
533
|
+
|
|
534
|
+
if not (sup_id or rule or glob_list):
|
|
535
|
+
warnings.append(f"{where}: skipping entry with no selector (id/rule/path)")
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
source = where
|
|
539
|
+
meta = {"expires": expires, "owner": owner, "ticket": ticket, "origin": "file", "source": source}
|
|
540
|
+
if sup_id:
|
|
541
|
+
suppressions.append(Suppression(reason=reason, id=sup_id, **meta))
|
|
542
|
+
if glob_list:
|
|
543
|
+
for glob in glob_list:
|
|
544
|
+
suppressions.append(Suppression(reason=reason, rule=rule, path=glob, **meta))
|
|
545
|
+
elif rule:
|
|
546
|
+
suppressions.append(Suppression(reason=reason, rule=rule, **meta))
|
|
547
|
+
return suppressions
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _parse_date(value: object) -> date | None:
|
|
551
|
+
if isinstance(value, datetime):
|
|
552
|
+
return value.date()
|
|
553
|
+
if isinstance(value, date):
|
|
554
|
+
return value
|
|
555
|
+
if isinstance(value, str):
|
|
556
|
+
try:
|
|
557
|
+
return date.fromisoformat(value.strip())
|
|
558
|
+
except ValueError:
|
|
559
|
+
return None
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
_UNTIL = re.compile(r"\buntil=(\d{4}-\d{2}-\d{2})\b")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def drop_expired(
|
|
567
|
+
suppressions: list[Suppression], warnings: list[str], *, today: date | None = None
|
|
568
|
+
) -> tuple[list[Suppression], list[Suppression]]:
|
|
569
|
+
"""Split into (live, expired). Expired entries are not applied — the
|
|
570
|
+
finding comes back — and each is reported (#238)."""
|
|
571
|
+
today = today or date.today()
|
|
572
|
+
live: list[Suppression] = []
|
|
573
|
+
expired: list[Suppression] = []
|
|
574
|
+
for sup in suppressions:
|
|
575
|
+
if sup.expires is not None and sup.expires < today:
|
|
576
|
+
expired.append(sup)
|
|
577
|
+
owner = f" (owner: {sup.owner})" if sup.owner else ""
|
|
578
|
+
warnings.append(
|
|
579
|
+
f"suppression {sup.label()} at {sup.source} expired on {sup.expires.isoformat()}{owner}; "
|
|
580
|
+
"no longer applied"
|
|
581
|
+
)
|
|
582
|
+
else:
|
|
583
|
+
live.append(sup)
|
|
584
|
+
return live, expired
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def load_suppress_file(
|
|
588
|
+
root: Path, warnings: list[str], *, explicit: Path | None = None
|
|
589
|
+
) -> list[Suppression]:
|
|
590
|
+
"""Load the repo-level baseline. ``explicit`` overrides auto-discovery."""
|
|
591
|
+
if explicit is not None:
|
|
592
|
+
if not explicit.exists():
|
|
593
|
+
warnings.append(f"Suppress file not found: {explicit}")
|
|
594
|
+
return []
|
|
595
|
+
return _load_yaml_entries(explicit, warnings)
|
|
596
|
+
for name in SUPPRESS_FILENAMES:
|
|
597
|
+
candidate = root / name
|
|
598
|
+
if candidate.exists() or candidate.is_symlink():
|
|
599
|
+
return _load_yaml_entries(candidate, warnings, root=root)
|
|
600
|
+
return []
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def scan_inline_suppressions(
|
|
604
|
+
root: Path, findings: list[Finding], warnings: list[str] | None = None
|
|
605
|
+
) -> list[Suppression]:
|
|
606
|
+
"""Scan the files cited by ``findings`` for inline ``attackmap:ignore``
|
|
607
|
+
directives. Only cited files are read — this is both efficient and
|
|
608
|
+
inherently scoped to what could actually be suppressed.
|
|
609
|
+
"""
|
|
610
|
+
cited: set[str] = set()
|
|
611
|
+
for finding in findings:
|
|
612
|
+
cited.update(_evidence_paths(finding))
|
|
613
|
+
|
|
614
|
+
suppressions: list[Suppression] = []
|
|
615
|
+
for rel in sorted(cited):
|
|
616
|
+
if any(part in _SKIP_INLINE_PARTS for part in Path(rel).parts):
|
|
617
|
+
continue
|
|
618
|
+
file_path = contained_file(root, rel)
|
|
619
|
+
try:
|
|
620
|
+
if file_path is None:
|
|
621
|
+
continue
|
|
622
|
+
text = file_path.read_text(encoding="utf-8", errors="replace")
|
|
623
|
+
except OSError:
|
|
624
|
+
continue
|
|
625
|
+
for lineno, line in enumerate(split_lines(text), start=1):
|
|
626
|
+
if "attackmap:ignore" not in line.lower():
|
|
627
|
+
continue
|
|
628
|
+
match = INLINE_DIRECTIVE.search(line)
|
|
629
|
+
if not match:
|
|
630
|
+
continue
|
|
631
|
+
rules_raw, reason = match.group(1), (match.group(2) or "").strip()
|
|
632
|
+
source = f"{rel}:{lineno}"
|
|
633
|
+
file_wide = "attackmap:ignore-file" in line.lower()
|
|
634
|
+
scope_line = None if file_wide else lineno
|
|
635
|
+
rule_tokens = [r.strip() for r in (rules_raw or "").split(",") if r.strip()]
|
|
636
|
+
if not rule_tokens and warnings is not None:
|
|
637
|
+
warnings.append(
|
|
638
|
+
f"bare attackmap:ignore{'-file' if file_wide else ''} at {source} matches every rule; "
|
|
639
|
+
"name the rule (attackmap:ignore[rule-id]) — see `attackmap rules`"
|
|
640
|
+
)
|
|
641
|
+
until = _UNTIL.search(reason)
|
|
642
|
+
for token in rule_tokens or [None]:
|
|
643
|
+
suppressions.append(
|
|
644
|
+
Suppression(
|
|
645
|
+
reason=reason,
|
|
646
|
+
rule=token,
|
|
647
|
+
path=rel,
|
|
648
|
+
line=scope_line,
|
|
649
|
+
expires=_parse_date(until.group(1)) if until else None,
|
|
650
|
+
origin="inline",
|
|
651
|
+
source=source,
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
return suppressions
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _selector_key(sup: Suppression) -> tuple:
|
|
658
|
+
# `expires` is part of the key: a PR that extends or removes the expiry of a
|
|
659
|
+
# base-trusted entry would otherwise revive it silently (GHSA-677m-4884-vg22).
|
|
660
|
+
return (sup.id, sup.rule and rule_slug(sup.rule), sup.path, sup.line, sup.expires)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _narrow_to_trusted_lines(
|
|
664
|
+
sup: Suppression, added_by_file: dict[str, set[int]], trusted_ref: str, warnings: list[str]
|
|
665
|
+
) -> Suppression:
|
|
666
|
+
"""``sup`` minus coverage of the line below it when that line was added
|
|
667
|
+
since ``trusted_ref``, with a warning saying so."""
|
|
668
|
+
if sup.line is None:
|
|
669
|
+
return sup
|
|
670
|
+
untrusted = {sup.line + 1} & added_by_file.get(sup.path or "", set())
|
|
671
|
+
if not untrusted:
|
|
672
|
+
return sup
|
|
673
|
+
warnings.append(
|
|
674
|
+
f"inline attackmap:ignore at {sup.source} doesn't cover line {sup.line + 1}: "
|
|
675
|
+
f"added since {trusted_ref}, so the suppression isn't extended to it"
|
|
676
|
+
)
|
|
677
|
+
return replace(sup, untrusted_lines=frozenset(untrusted))
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def collect_suppressions(
|
|
681
|
+
root: Path,
|
|
682
|
+
findings: list[Finding],
|
|
683
|
+
*,
|
|
684
|
+
enable_inline: bool = True,
|
|
685
|
+
explicit_file: Path | None = None,
|
|
686
|
+
trusted_ref: str | None = None,
|
|
687
|
+
allow_pr_suppressions: bool = False,
|
|
688
|
+
) -> tuple[SuppressionSet, list[str]]:
|
|
689
|
+
"""Gather suppress-file + inline suppressions into a ready-to-apply set.
|
|
690
|
+
|
|
691
|
+
With ``trusted_ref`` (#238) only suppressions that already exist at that
|
|
692
|
+
git ref (the PR's base) take effect. Entries added or changed since, and
|
|
693
|
+
inline directives on lines added since, are *pending*: reported on the
|
|
694
|
+
returned set's ``pending`` list and not applied, unless
|
|
695
|
+
``allow_pr_suppressions``. An inline directive that is itself trusted
|
|
696
|
+
still doesn't cover a line added since (the line below it): the
|
|
697
|
+
suppression would otherwise extend to code the PR wrote. Expired entries
|
|
698
|
+
are never applied.
|
|
699
|
+
"""
|
|
700
|
+
warnings: list[str] = []
|
|
701
|
+
suppressions = load_suppress_file(root, warnings, explicit=explicit_file)
|
|
702
|
+
inline = scan_inline_suppressions(root, findings, warnings) if enable_inline else []
|
|
703
|
+
pending: list[Suppression] = []
|
|
704
|
+
|
|
705
|
+
if trusted_ref is not None:
|
|
706
|
+
validate_ref(root, trusted_ref)
|
|
707
|
+
trusted_keys: set[tuple] = set()
|
|
708
|
+
source_file = explicit_file if explicit_file is not None else next(
|
|
709
|
+
(root / n for n in SUPPRESS_FILENAMES if (root / n).exists()), None
|
|
710
|
+
)
|
|
711
|
+
names = [source_file.name] if source_file is not None and explicit_file is None else list(SUPPRESS_FILENAMES)
|
|
712
|
+
for name in names:
|
|
713
|
+
text = show_file(root, trusted_ref, name)
|
|
714
|
+
if text is not None:
|
|
715
|
+
trusted_keys = {_selector_key(s) for s in parse_suppress_text(text, name, [])}
|
|
716
|
+
break
|
|
717
|
+
pending.extend(s for s in suppressions if _selector_key(s) not in trusted_keys)
|
|
718
|
+
added_by_file: dict[str, set[int]] = {}
|
|
719
|
+
for sup in inline:
|
|
720
|
+
if sup.path not in added_by_file:
|
|
721
|
+
added_by_file[sup.path or ""] = added_lines(root, trusted_ref, sup.path or "")
|
|
722
|
+
added = added_by_file[sup.path or ""]
|
|
723
|
+
directive_line = int(sup.source.rsplit(":", 1)[1]) if ":" in sup.source else None
|
|
724
|
+
if directive_line in added:
|
|
725
|
+
pending.append(sup)
|
|
726
|
+
if not allow_pr_suppressions:
|
|
727
|
+
pending_ids = {id(s) for s in pending}
|
|
728
|
+
suppressions = [s for s in suppressions if id(s) not in pending_ids]
|
|
729
|
+
inline = [
|
|
730
|
+
_narrow_to_trusted_lines(s, added_by_file, trusted_ref, warnings)
|
|
731
|
+
for s in inline
|
|
732
|
+
if id(s) not in pending_ids
|
|
733
|
+
]
|
|
734
|
+
|
|
735
|
+
live, expired = drop_expired(suppressions + inline, warnings)
|
|
736
|
+
suppset = SuppressionSet(live)
|
|
737
|
+
suppset.pending = pending
|
|
738
|
+
suppset.pending_applied = allow_pr_suppressions
|
|
739
|
+
suppset.expired = expired
|
|
740
|
+
return suppset, warnings
|