ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_scan.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Syntax-aware scanners for suppression and waiver directives."""
4
5from __future__ import annotations
6
7import hashlib
8import re
9import subprocess
10import tomllib
11from collections import Counter
12from dataclasses import dataclass, field
13from pathlib import Path
14
15from suppression_baseline_scan import scan_baseline_repository
16from suppression_build_controls import compiler_records
17from suppression_c_control_scan import C_CONTROL_HINT_RE, scan_c_controls
18from suppression_c_controls import scan_c_control_file
19from suppression_catalog import (
20 BINARY_SUFFIXES,
21 KNOWN_CPPCHECK_RULES,
22 REQUIRED_FAMILIES,
23 SUPPORTED_HINT_RE,
24 UNSUPPORTED_CATEGORIES,
25 language,
26 ownership,
27)
28from suppression_checker_nonfatal import scan_checker_nonfatal_controls
29from suppression_checker_scope import EXPECTED_VALUES, scan_checker_scope_controls
30from suppression_clang_tidy import scan_clang_tidy_config
31from suppression_comment_lex import extract_comments
32from suppression_control_scan import scan_control_file
33from suppression_coverage_scan import scan_coverage_masks
34from suppression_generated_markers import generated_records
35from suppression_governance import scan_ci_parity_exemptions, scan_governance_file
36from suppression_hardware_todo import scan_hardware_todo_controls
37from suppression_identity import assign_identities
38from suppression_inline_scan import (
39 Recognition,
40 _concerns,
41 _scan_comments,
42 _scan_raw_nolint,
43 stranded_branch_findings,
44)
45from suppression_ledger import apply_ledger
46from suppression_model import Finding, Inventory, Suppression
47from suppression_shell_scan import shell_status_records, yaml_shell_block_status_records
48from suppression_tool_controls import (
49 configured_optional_tools,
50 scan_tool_configs,
51 scan_tool_sources,
52)
53from suppression_validate import (
54 deduplicate,
55 validate_cppcheck_anchors,
56 validate_fingerprints,
57 validate_regions,
58)
59
60MIN_REPOSITORY_FILES = 2000
61MIN_SUPPRESSIONS = 50
62MIN_FAMILY_COUNTS = {
63 "coverage-mask": 2,
64 "mcdc-deactivation": 77,
65}
66EXPECTED_EXACT_FAMILY_COUNTS = {
67 "ansible-lint-config": 0,
68 "other-language-control": 0,
69 "security-analysis-control": 0,
70 "test-control": 16,
71}
72GOVERNANCE_COUNT_CONTRACTS = {
73 "ansible-lint-config": ("family", "ansible-lint-config", 0),
74 "ci-parity-exemptions": ("family", "ci-parity-exemption", 3),
75 "generated-artifacts": ("family", "generated-artifact", 10),
76 "doxygen-controls": ("family", "documentation-control", 17),
77 "security-analysis-controls": ("family", "security-analysis-control", 0),
78 "other-language-controls": ("family", "other-language-control", 0),
79 "gitignore-scope-exemptions": ("tool", "gitignore-scope", 1),
80 "hardware-canned-stub-waivers": ("tool", "check-no-silent-stubs", 0),
81 "checker-scope-values": ("family", "checker-scope-control", EXPECTED_VALUES),
82 "checker-nonfatal-declarations": ("directive", "nonfatal-declaration", 10),
83 "checker-nonfatal-invocations": ("directive", "nonfatal-invocation", 0),
84 "vendor-encoding-exemptions": ("family", "encoding-exemption", 4),
85}
86
87
88@dataclass
89class CppcheckReasonState:
90 """Rationale association state for the central cppcheck suppression list."""
91
92 in_section: bool = False
93 section_notes: list[str] = field(default_factory=list)
94 local_notes: list[str] = field(default_factory=list)
95 frozen_reason: str = ""
96 last_was_entry: bool = False
97
98 def add_comment(self, note: str) -> None:
99 """Consume one comment or paired section delimiter."""
100 if note and set(note) <= {"-"}:
101 if self.in_section:
102 self.frozen_reason = " ".join(self.section_notes).strip()
103 self.in_section = False
104 else:
105 self.in_section = True
106 self.section_notes = []
107 self.frozen_reason = ""
108 self.local_notes = []
109 self.last_was_entry = False
110 elif self.in_section:
111 self.section_notes.append(note)
112 else:
113 if self.last_was_entry:
114 self.local_notes = []
115 self.frozen_reason = ""
116 self.local_notes.append(note)
117 self.last_was_entry = False
118
119 def reason_for_entry(self) -> str:
120 """Freeze an open section and return the rationale for the next entry."""
121 if self.in_section:
122 self.frozen_reason = " ".join(self.section_notes).strip()
123 self.in_section = False
124 self.last_was_entry = True
125 return self.frozen_reason or " ".join(self.local_notes).strip()
126
127
128def git_paths(root: Path) -> tuple[list[str], list[Finding]]:
129 """Enumerate present tracked and nonignored untracked repository files."""
130 proc = subprocess.run(
131 ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], # noqa: S607 -- fixed repository Git census
132 cwd=root,
133 capture_output=True,
134 check=False,
135 )
136 if proc.returncode != 0:
137 message = proc.stderr.decode("utf-8", errors="replace").strip()
138 return [], [Finding("git-enumeration", message or "git ls-files failed")]
139 if proc.stderr:
140 message = proc.stderr.decode("utf-8", errors="replace").strip()
141 return [], [Finding("git-enumeration", message or "git ls-files emitted diagnostics")]
142 return decode_git_paths(proc.stdout, root)
143
144
145def decode_git_paths(data: bytes, _root: Path) -> tuple[list[str], list[Finding]]:
146 """Decode Git's NUL list and reject names that are not valid UTF-8."""
147 try:
148 decoded = data.decode("utf-8", errors="strict").split("\0")
149 except UnicodeDecodeError as exc:
150 return [], [Finding("git-enumeration", f"non-UTF-8 repository path: {exc}")]
151 paths = [path for path in decoded if path]
152 return sorted(paths), []
153
154
155def _read_text(root: Path, rel: str) -> tuple[str | None, Finding | None]:
156 """Read one candidate as UTF-8 text, classifying binary data explicitly."""
157 path = root / rel
158 try:
159 resolved = path.resolve(strict=True)
160 resolved.relative_to(root)
161 except (OSError, ValueError) as exc:
162 if isinstance(exc, ValueError):
163 failure = Finding("unsafe-symlink", "path resolves outside repository")
164 else:
165 failure = Finding("read-error", str(exc))
166 return None, failure
167 if path.suffix.lower() in BINARY_SUFFIXES:
168 return None, None
169 try:
170 with resolved.open("rb") as handle:
171 prefix = handle.read(8192)
172 if b"\0" in prefix:
173 return None, None
174 data = prefix + handle.read()
175 except OSError as exc:
176 return None, Finding("read-error", str(exc))
177 try:
178 return data.decode("utf-8"), None
179 except UnicodeDecodeError as exc:
180 return None, Finding("invalid-text-encoding", str(exc))
181
182
183def _ruff_config_record(path: str, line: int, recognition: Recognition) -> Suppression:
184 """Build one source-located Ruff central-configuration inventory row."""
185 return Suppression(
186 path,
187 line,
188 1,
189 "python",
190 "ruff",
191 recognition.rule,
192 recognition.directive,
193 recognition.scope,
194 recognition.reason,
195 "central-config",
196 ownership(path),
197 _concerns(recognition.rule, recognition.reason),
198 )
199
200
201def _toml_section_lines(text: str, name: str) -> list[tuple[int, str]]:
202 """Return source-located lines from one exact TOML table."""
203 result: list[tuple[int, str]] = []
204 active = False
205 for line_no, raw in enumerate(text.splitlines(), start=1):
206 stripped = raw.strip()
207 if stripped.startswith("[") and stripped.endswith("]"):
208 active = stripped == f"[{name}]"
209 elif active:
210 result.append((line_no, raw))
211 return result
212
213
214def _ruff_list_entries(text: str, section: str, key: str) -> list[tuple[int, str, str]]:
215 """Return line, string value, and rationale from one Ruff string array."""
216 result: list[tuple[int, str, str]] = []
217 active = False
218 pattern = re.compile(r'^\s*"(?P<value>[^"]+)"\s*,?\s*(?:#\s*(?P<reason>.*))?$')
219 for line_no, raw in _toml_section_lines(text, section):
220 stripped = raw.strip()
221 if stripped == f"{key} = [":
222 active = True
223 elif active and stripped == "]":
224 break
225 elif active and (match := pattern.fullmatch(raw)) is not None:
226 result.append((line_no, match.group("value"), (match.group("reason") or "").strip()))
227 return result
228
229
230def _ruff_per_file_entries(text: str) -> list[tuple[int, str, str, str]]:
231 """Return line, path, rule, and rationale for single-rule Ruff file waivers."""
232 pattern = re.compile(
233 r'^\s*"(?P<path>[^"]+)"\s*=\s*\‍[\s*"(?P<rule>[^"]+)"\s*\‍]'
234 r"\s*(?:#\s*(?P<reason>.*))?$"
235 )
236 result: list[tuple[int, str, str, str]] = []
237 for line_no, raw in _toml_section_lines(text, "tool.ruff.lint.per-file-ignores"):
238 if (match := pattern.fullmatch(raw)) is not None:
239 result.append(
240 (
241 line_no,
242 match.group("path"),
243 match.group("rule"),
244 (match.group("reason") or "").strip(),
245 )
246 )
247 return result
248
249
250def _ruff_config_records(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
251 """Inventory Ruff global ignores, per-file ignores, and path exclusions."""
252 if path != "pyproject.toml":
253 return [], []
254 try:
255 parsed = tomllib.loads(text)
256 ruff = parsed["tool"]["ruff"]
257 lint = ruff["lint"]
258 except (KeyError, TypeError, tomllib.TOMLDecodeError) as exc:
259 return [], [Finding("malformed-ruff-config", str(exc), path)]
260
261 excludes = _ruff_list_entries(text, "tool.ruff", "extend-exclude")
262 ignores = _ruff_list_entries(text, "tool.ruff.lint", "ignore")
263 per_file = _ruff_per_file_entries(text)
264 records = [
265 _ruff_config_record(
266 path,
267 line,
268 Recognition("python", "ruff", value, "ruff extend-exclude", "path-pattern", reason),
269 )
270 for line, value, reason in excludes
271 ]
272 records.extend(
273 _ruff_config_record(
274 path,
275 line,
276 Recognition("python", "ruff", value, "ruff ignore", "repository", reason),
277 )
278 for line, value, reason in ignores
279 )
280 records.extend(
281 _ruff_config_record(
282 path,
283 line,
284 Recognition("python", "ruff", rule, "ruff per-file-ignore", f"file:{target}", reason),
285 )
286 for line, target, rule, reason in per_file
287 )
288
289 expected_excludes = ruff.get("extend-exclude", [])
290 expected_ignores = lint.get("ignore", [])
291 expected_per_file = lint.get("per-file-ignores", {})
292 observed_per_file: dict[str, list[str]] = {}
293 for _, target, rule, _ in per_file:
294 observed_per_file.setdefault(target, []).append(rule)
295 if (
296 [value for _, value, _ in excludes] != expected_excludes
297 or [value for _, value, _ in ignores] != expected_ignores
298 or observed_per_file != expected_per_file
299 ):
300 message = "source-location parser does not cover every active Ruff waiver"
301 return records, [Finding("malformed-ruff-config", message, path)]
302 return records, []
303
304
305def _unsupported_counts(path: str, text: str, counts: Counter[str]) -> None:
306 """Count hints for declared phase-one gaps without claiming recognition."""
307 item = Path(path)
308 scanner_dir = Path("scripts") / "checks"
309 if item.parent == scanner_dir and (
310 item.name.startswith("suppression_") or item.name == "check_suppressions.py"
311 ):
312 return
313 for name, pattern in UNSUPPORTED_CATEGORIES:
314 counts[name] += len(pattern.findall(text))
315
316
317def _append_unsupported(inventory: Inventory, counts: Counter[str]) -> None:
318 """Make every known absent recognizer an explicit non-clean check result."""
319 if not UNSUPPORTED_CATEGORIES:
320 return
321 for name, _ in UNSUPPORTED_CATEGORIES:
322 count = counts[name]
323 message = f"phase-one recognizer absent; {count} unverified text hint(s)"
324 inventory.findings.append(Finding("unsupported-category", f"{name}: {message}"))
325 message = "phase-one raw hint census excludes its scanner sources to avoid regex self-hits"
326 inventory.findings.append(Finding("scanner-self-exemption", message))
327
328
329def _cppcheck_list(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
330 """Parse the central cppcheck suppressions-list format."""
331 records: list[Suppression] = []
332 findings: list[Finding] = []
333 state = CppcheckReasonState()
334 for line_no, raw in enumerate(text.splitlines(), start=1):
335 stripped = raw.strip()
336 if not stripped:
337 continue
338 if stripped.startswith("#"):
339 note = stripped[1:].strip()
340 state.add_comment(note)
341 continue
342 parts = stripped.split(":", 2)
343 rule = parts[0].strip()
344 if rule not in KNOWN_CPPCHECK_RULES:
345 findings.append(Finding("malformed-cppcheck-list", stripped, path, line_no))
346 continue
347 target = parts[1].strip() if len(parts) > 1 else "*"
348 scope = ":".join(parts[1:]).strip() if len(parts) > 1 else "repository"
349 reason = state.reason_for_entry()
350 concerns = _concerns(rule, reason)
351 records.append(
352 Suppression(
353 path,
354 line_no,
355 1,
356 "cppcheck",
357 "cppcheck",
358 rule,
359 "suppressions-list",
360 scope,
361 reason,
362 "central-list",
363 ownership(path),
364 concerns,
365 )
366 )
367 if len(parts) > 1 and not target:
368 findings.append(Finding("malformed-cppcheck-list", "empty target", path, line_no))
369 if state.in_section:
370 findings.append(Finding("malformed-cppcheck-list", "unterminated rationale section", path))
371 return records, findings
372
373
374def _nonvacuity(inventory: Inventory) -> None:
375 """Reject collapsed enumeration or recognizer coverage as malformed."""
376 if inventory.files_scanned < MIN_REPOSITORY_FILES:
377 files = inventory.files_scanned
378 message = f"only {files} files enumerated; floor is {MIN_REPOSITORY_FILES}"
379 inventory.findings.append(Finding("vacuous-files", message))
380 if len(inventory.suppressions) < MIN_SUPPRESSIONS:
381 count = len(inventory.suppressions)
382 message = f"only {count} suppressions found; floor is {MIN_SUPPRESSIONS}"
383 inventory.findings.append(Finding("vacuous-inventory", message))
384 seen = {item.family for item in inventory.suppressions}
385 exact_expected = set(EXPECTED_EXACT_FAMILY_COUNTS)
386 for family in sorted(REQUIRED_FAMILIES - seen - exact_expected):
387 inventory.findings.append(Finding("missing-family", family))
388 counts = Counter(item.family for item in inventory.suppressions)
389 for family, expected in EXPECTED_EXACT_FAMILY_COUNTS.items():
390 if counts[family] != expected:
391 message = f"{family}={counts[family]}; audited exact contract is {expected}"
392 inventory.findings.append(Finding("unexpected-family-count", message))
393 for family, floor in MIN_FAMILY_COUNTS.items():
394 if counts[family] < floor:
395 message = f"{family}={counts[family]}; audited floor is {floor}"
396 inventory.findings.append(Finding("vacuous-family", message))
397
398
399def _governance_count_contracts(inventory: Inventory) -> None:
400 """Lock audited live and zero-live semantic governance populations."""
401 for label, (attribute, value, expected) in GOVERNANCE_COUNT_CONTRACTS.items():
402 count = sum(getattr(item, attribute) == value for item in inventory.suppressions)
403 if count != expected:
404 message = f"{label}={count}; audited semantic contract is {expected}"
405 inventory.findings.append(Finding("unexpected-governance-count", message))
406
407
408def _scan_lexed_path(
409 inventory: Inventory,
410 rel: str,
411 text: str,
412 c_family: bool,
413 active_tools: frozenset[str],
414) -> None:
415 """Run comment, compiler, shell, and C-control recognizers after lexing."""
416 comments, findings = extract_comments(rel, text)
417 inventory.findings.extend(Finding(item.code, item.message, rel, item.line) for item in findings)
418 if c_family:
419 records, findings = _scan_raw_nolint(rel, text)
420 inventory.suppressions.extend(records)
421 inventory.findings.extend(findings)
422 # Interior lines of a multi-line block comment look like an unfinished
423 # statement, so the detector is told where the comments are.
424 comment_lines = frozenset(
425 line
426 for item in comments
427 for line in range(item.line, item.line + item.text.count("\n") + 1)
428 )
429 inventory.findings.extend(stranded_branch_findings(rel, text, comment_lines))
430 records, findings = _scan_comments(rel, comments, active_tools, include_nolint=not c_family)
431 inventory.suppressions.extend(records)
432 inventory.findings.extend(findings)
433 inventory.suppressions.extend(compiler_records(rel, text))
434 if c_family:
435 records, findings = scan_c_controls(rel, text, comments)
436 inventory.suppressions.extend(records)
437 inventory.findings.extend(findings)
438 records, findings = shell_status_records(rel, text)
439 inventory.suppressions.extend(records)
440 inventory.findings.extend(findings)
441 records, findings = yaml_shell_block_status_records(rel, text)
442 inventory.suppressions.extend(records)
443 inventory.findings.extend(findings)
444
445
446def _scan_text_path(
447 inventory: Inventory,
448 rel: str,
449 text: str,
450 unsupported: Counter[str],
451 active_tools: frozenset[str],
452) -> None:
453 """Run every syntax recognizer over one decoded repository file."""
454 inventory.text_files += 1
455 _unsupported_counts(rel, text, unsupported)
456 records, findings = scan_c_control_file(rel, text)
457 inventory.suppressions.extend(records)
458 inventory.findings.extend(findings)
459 first_line = text.partition("\n")[0]
460 c_family = language(rel, first_line) == "c-family"
461 needs_lexing = (
462 not c_family
463 or SUPPORTED_HINT_RE.search(text) is not None
464 or text.rstrip(" \t\n").endswith("\\")
465 or C_CONTROL_HINT_RE.search(text) is not None
466 )
467 if needs_lexing:
468 _scan_lexed_path(inventory, rel, text, c_family, active_tools)
469 if rel == ".cppcheck-suppressions":
470 records, findings = _cppcheck_list(rel, text)
471 inventory.suppressions.extend(records)
472 inventory.findings.extend(findings)
473 for config_scanner in (_ruff_config_records, scan_tool_configs, scan_tool_sources):
474 records, findings = config_scanner(rel, text)
475 inventory.suppressions.extend(records)
476 inventory.findings.extend(findings)
477 records, findings = scan_clang_tidy_config(rel, text)
478 inventory.suppressions.extend(records)
479 inventory.findings.extend(findings)
480 records, findings = scan_control_file(rel, text)
481 inventory.suppressions.extend(records)
482 inventory.findings.extend(findings)
483
484 records, findings = scan_coverage_masks(rel, text)
485 inventory.suppressions.extend(records)
486 inventory.findings.extend(findings)
487
488
489def _scan_repository_governance(inventory: Inventory, root: Path, paths: list[str]) -> None:
490 """Run controls whose truth depends on repository-wide bindings/callers."""
491 for scanner in (
492 scan_ci_parity_exemptions,
493 scan_hardware_todo_controls,
494 scan_checker_scope_controls,
495 scan_checker_nonfatal_controls,
496 ):
497 records, findings = scanner(root, paths)
498 inventory.suppressions.extend(records)
499 inventory.findings.extend(findings)
500
501
502def _vendor_encoding_row(root: Path, rel: str, finding: Finding) -> Suppression | Finding:
503 """Bind one vendored undecodable file to its exact blob hash."""
504 try:
505 blob = hashlib.sha256((root / rel).read_bytes()).hexdigest()
506 except OSError as exc:
507 return Finding("read-error", str(exc), rel)
508 return Suppression(
509 rel,
510 1,
511 1,
512 "encoding-exemption",
513 "suppression-scanner",
514 "invalid-text-encoding",
515 "vendor-encoding-exemption",
516 f"blob:sha256:{blob}",
517 f"vendored legacy text is not decoded or scanned: {finding.message}",
518 "vendor-boundary",
519 "vendor",
520 (),
521 )
522
523
524@dataclass(frozen=True)
525class _ScanContext:
526 """Per-run scan inputs shared by every candidate path."""
527
528 root: Path
529 unsupported: Counter[str]
530 active_tools: frozenset[str]
531 tracked_paths: frozenset[str]
532
533
534def _scan_one_path(inventory: Inventory, context: _ScanContext, rel: str) -> None:
535 """Read and scan one repository candidate through every recognizer."""
536 root = context.root
537 text, finding = _read_text(root, rel)
538 if finding is not None:
539 if finding.code == "invalid-text-encoding" and ownership(rel) == "vendor":
540 row = _vendor_encoding_row(root, rel, finding)
541 if isinstance(row, Suppression):
542 inventory.suppressions.append(row)
543 inventory.binary_files += 1
544 else:
545 inventory.findings.append(row)
546 return
547 inventory.findings.append(Finding(finding.code, finding.message, rel, finding.line))
548 return
549 if text is None:
550 inventory.binary_files += 1
551 return
552 _scan_text_path(inventory, rel, text, context.unsupported, context.active_tools)
553 records, findings = scan_governance_file(rel, text)
554 inventory.suppressions.extend(records)
555 inventory.findings.extend(findings)
556 records, findings = generated_records(rel, text, context.tracked_paths, root)
557 inventory.suppressions.extend(records)
558 inventory.findings.extend(findings)
559
560
561def scan_paths(root: Path, paths: list[str], *, enforce_floors: bool = False) -> Inventory:
562 """Scan explicit repo-relative paths through the production recognizers."""
563 inventory = Inventory(files_scanned=len(paths))
564 try:
565 resolved_root = root.resolve(strict=True)
566 except OSError as exc:
567 inventory.findings.append(Finding("read-error", str(exc)))
568 return inventory
569 unsupported: Counter[str] = Counter()
570 active_tools = configured_optional_tools(paths)
571 tracked_paths = frozenset(paths)
572 context = _ScanContext(resolved_root, unsupported, active_tools, tracked_paths)
573 for rel in paths:
574 _scan_one_path(inventory, context, rel)
575 if enforce_floors:
576 _scan_repository_governance(inventory, resolved_root, paths)
577 records, findings = scan_baseline_repository(
578 resolved_root, paths, enforce_floors=enforce_floors
579 )
580 inventory.suppressions.extend(records)
581 inventory.findings.extend(findings)
582 deduplicate(inventory)
583 validate_fingerprints(inventory)
584 validate_regions(inventory)
585 validate_cppcheck_anchors(inventory, resolved_root)
586 assign_identities(inventory, resolved_root)
587 _append_unsupported(inventory, unsupported)
588 if enforce_floors:
589 apply_ledger(inventory, resolved_root)
590 _governance_count_contracts(inventory)
591 _nonvacuity(inventory)
592 return inventory
593
594
595def scan_repository(root: Path) -> tuple[Inventory, list[str]]:
596 """Enumerate and scan the live repository with non-vacuity guards."""
597 paths, findings = git_paths(root)
598 inventory = scan_paths(root, paths, enforce_floors=True) if paths else Inventory()
599 inventory.findings.extend(findings)
600 return inventory, paths