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