ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_governance.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Typed repository-governance controls that are not ordinary lint comments."""
4
5from __future__ import annotations
6
7import re
8import sys
9from dataclasses import dataclass
10from pathlib import Path
11
12import yaml
13from check_gitignore_scope import marker_bindings
14from suppression_catalog import ownership
15from suppression_comment_lex import extract_comments
16from suppression_model import Finding, Suppression
17
18CI_DIR = Path(__file__).resolve().parents[1] / "ci"
19if str(CI_DIR) not in sys.path:
20 sys.path.insert(0, str(CI_DIR))
21from check_ci_parity import ( # noqa: E402 -- follows the CI_DIR sys.path insert above
22 classify_step,
23 iter_run_steps,
24)
25
26ANSIBLE_CONFIG_NAMES = frozenset({".ansible-lint", ".ansible-lint.yml", ".ansible-lint.yaml"})
27ANSIBLE_LIST_KEYS = frozenset({"skip_list", "warn_list", "exclude_paths"})
28
29# No non-Ruff global exclusion authority exists in this tree. This typed
30# registry is deliberately empty: adding a real tool config requires naming
31# its exact file, table and key here rather than reviving a prose regex.
32GLOBAL_EXCLUSION_AUTHORITIES: dict[str, tuple[str, str]] = {}
33
34SECURITY_RULES = (
35 (
36 re.compile(
37 r"^nosemgrep(?:\s*:\s*(?P<rule>[A-Za-z0-9_.-]+))?"
38 r"(?:\s+--\s+(?P<reason>\S.*))?$",
39 re.IGNORECASE,
40 ),
41 "semgrep",
42 ),
43 (re.compile(r"^NOSONAR(?:\s+--\s+(?P<reason>\S.*))?$"), "sonarqube"),
44 (
45 re.compile(
46 r"^lgtm\‍[(?P<rule>[A-Za-z0-9_./-]+)\‍]"
47 r"(?:\s+--\s+(?P<reason>\S.*))?$",
48 re.IGNORECASE,
49 ),
50 "codeql-legacy",
51 ),
52)
53OTHER_LANGUAGE_RULES = {
54 ".java": (
55 "java",
56 re.compile(
57 r'^@SuppressWarnings\‍(\s*"(?P<rule>[A-Za-z0-9_.-]+)"\s*\‍)\s*(?://\s*(?P<reason>\S.*))?$'
58 ),
59 ),
60 ".kt": (
61 "kotlin",
62 re.compile(
63 r'^@Suppress\‍(\s*"(?P<rule>[A-Za-z0-9_.-]+)"\s*\‍)\s*(?://\s*(?P<reason>\S.*))?$'
64 ),
65 ),
66 ".rs": (
67 "rust",
68 re.compile(
69 r"^#\‍[(?P<kind>allow|expect)\‍((?P<rule>[A-Za-z0-9_:.-]+)\‍)\‍]\s*(?://\s*(?P<reason>\S.*))?$"
70 ),
71 ),
72 ".go": (
73 "go",
74 re.compile(r"^//nolint:(?P<rule>[A-Za-z0-9_,.-]+)\s+//\s*(?P<reason>\S.*)$"),
75 ),
76}
77
78
79@dataclass(frozen=True)
80class GovernanceSpec:
81 """Normalized fields for one governance control."""
82
83 family: str
84 tool: str
85 rule: str
86 directive: str
87 scope: str
88 reason: str
89 provenance: str
90
91
92def _record(path: str, line: int, spec: GovernanceSpec) -> Suppression:
93 """Build one deterministic governance row."""
94 concerns = () if spec.reason else ("blank-reason",)
95 return Suppression(
96 path,
97 line,
98 1,
99 spec.family,
100 spec.tool,
101 spec.rule,
102 spec.directive,
103 spec.scope,
104 spec.reason,
105 spec.provenance,
106 ownership(path),
107 concerns,
108 )
109
110
111def _ansible_key_records(
112 path: str,
113 key: str,
114 values: object,
115 lines: list[str],
116 key_line: int,
117) -> tuple[list[Suppression], list[Finding]]:
118 """Parse one ansible-lint list authority and its item-local reasons."""
119 if not isinstance(values, list):
120 finding = Finding("malformed-ansible-lint-config", f"{key} is not a list", path)
121 return [], [finding]
122 records: list[Suppression] = []
123 findings: list[Finding] = []
124 for offset, value in enumerate(values):
125 if not isinstance(value, str) or not value.strip():
126 findings.append(
127 Finding(
128 "malformed-ansible-lint-config",
129 f"{key} has non-string item",
130 path,
131 key_line,
132 )
133 )
134 continue
135 item_line = next(
136 (
137 number
138 for number, raw in enumerate(lines[key_line - 1 :], start=key_line)
139 if re.match(rf"^\s*-\s*{re.escape(value)}(?:\s*(?:#.*)?)$", raw)
140 ),
141 key_line + offset + 1,
142 )
143 raw = lines[item_line - 1] if item_line <= len(lines) else ""
144 reason = raw.partition("#")[2].strip()
145 spec = GovernanceSpec(
146 "ansible-lint-config",
147 "ansible-lint",
148 value,
149 key.replace("_", "-"),
150 f"config:{key}",
151 reason,
152 "central-config",
153 )
154 records.append(_record(path, item_line, spec))
155 return records, findings
156
157
158def scan_ansible_lint_config(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
159 """Parse exact ansible-lint list authorities, not task/docs substrings."""
160 if Path(path).name not in ANSIBLE_CONFIG_NAMES:
161 return [], []
162 try:
163 doc = yaml.safe_load(text)
164 except yaml.YAMLError as exc:
165 return [], [Finding("malformed-ansible-lint-config", str(exc), path)]
166 if not isinstance(doc, dict):
167 return [], [Finding("malformed-ansible-lint-config", "top level is not a mapping", path)]
168 lines = text.splitlines()
169 records: list[Suppression] = []
170 findings: list[Finding] = []
171 for key in ANSIBLE_LIST_KEYS:
172 if key not in doc:
173 continue
174 key_line = next(
175 (
176 number
177 for number, raw in enumerate(lines, start=1)
178 if re.match(rf"^\s*{key}\s*:", raw)
179 ),
180 1,
181 )
182 rows, problems = _ansible_key_records(path, key, doc[key], lines, key_line)
183 records.extend(rows)
184 findings.extend(problems)
185 return records, findings
186
187
188def scan_registered_global_exclusions(
189 path: str, _text: str
190) -> tuple[list[Suppression], list[Finding]]:
191 """Parse only explicitly registered non-Ruff exclusion authorities."""
192 authority = GLOBAL_EXCLUSION_AUTHORITIES.get(path)
193 if authority is None:
194 return [], []
195 table, key = authority
196 # No authority is registered today. Keep the branch executable and
197 # fail-closed for the first future registration rather than accepting a
198 # generic `exclude_files` substring anywhere in the tree.
199 message = f"registered parser not implemented for {table}.{key}"
200 return [], [Finding("malformed-global-exclusion-config", message, path)]
201
202
203def scan_gitignore_exemptions(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
204 """Inventory the exact marker-to-unanchored-pattern bindings the gate consumes."""
205 if path != ".gitignore":
206 return [], []
207 bindings, errors = marker_bindings(text)
208 records = [
209 _record(
210 path,
211 item.marker_line,
212 GovernanceSpec(
213 "project-policy",
214 "gitignore-scope",
215 "unanchored-directory-exemption",
216 "gitignore-scope-ok",
217 f"pattern:{item.pattern}",
218 item.reason,
219 "bound-comment-block",
220 ),
221 )
222 for item in bindings
223 ]
224 findings = [
225 Finding("malformed-gitignore-scope-marker", message, path, line) for line, message in errors
226 ]
227 return records, findings
228
229
230def scan_ci_parity_exemptions(
231 root: Path, paths: list[str]
232) -> tuple[list[Suppression], list[Finding]]:
233 """Inventory active infra run steps through the parity checker's parser."""
234 records: list[Suppression] = []
235 findings: list[Finding] = []
236 workflows = [
237 rel
238 for rel in paths
239 if rel.startswith(".github/workflows/") and Path(rel).suffix in {".yml", ".yaml"}
240 ]
241 for rel in workflows:
242 workflow = root / rel
243 try:
244 text = workflow.read_text(encoding="utf-8")
245 steps = list(iter_run_steps(workflow))
246 except (OSError, yaml.YAMLError) as exc:
247 findings.append(Finding("malformed-ci-parity-workflow", str(exc), rel))
248 continue
249 for step in steps:
250 kind, _gates, reason = classify_step(step.body)
251 if kind != "infra":
252 continue
253 label = step.label
254 line = next(
255 (
256 number
257 for number, raw in enumerate(text.splitlines(), start=1)
258 if re.match(rf"^\s*-?\s*name:\s*['\"]?{re.escape(label)}['\"]?\s*$", raw)
259 ),
260 1,
261 )
262 records.append(
263 _record(
264 rel,
265 line,
266 GovernanceSpec(
267 "ci-parity-exemption",
268 "ci-parity",
269 "infrastructure-step",
270 "ci-parity: infra",
271 f"job:{step.job_name}/step:{label}",
272 reason or "",
273 "workflow-run-step",
274 ),
275 )
276 )
277 return records, findings
278
279
280def _doxygen_assignments(text: str) -> tuple[list[tuple[int, str, list[str], str]], list[Finding]]:
281 """Return top-level Doxyfile assignments with continuation values/reasons."""
282 rows: list[tuple[int, str, list[str], str]] = []
283 findings: list[Finding] = []
284 lines = text.splitlines()
285 index = 0
286 comments: list[str] = []
287 while index < len(lines):
288 raw = lines[index]
289 stripped = raw.strip()
290 if stripped.startswith("#"):
291 comments.append(stripped[1:].strip())
292 index += 1
293 continue
294 match = re.match(r"^(?P<key>[A-Z][A-Z0-9_]*)\s*(?P<op>\+?=)\s*(?P<value>.*)$", raw)
295 if match is None:
296 if stripped:
297 comments = []
298 index += 1
299 continue
300 line_no = index + 1
301 values: list[str] = []
302 part = match.group("value").strip()
303 while True:
304 continued = part.endswith("\\")
305 if continued:
306 part = part[:-1].rstrip()
307 values.extend(part.split())
308 if not continued:
309 break
310 index += 1
311 if index >= len(lines):
312 findings.append(
313 Finding(
314 "malformed-doxygen-config",
315 f"unterminated {match.group('key')}",
316 "Doxyfile",
317 line_no,
318 )
319 )
320 break
321 part = lines[index].strip()
322 reason = " ".join(item for item in comments if item).strip()
323 rows.append((line_no, match.group("key"), values, reason))
324 comments = []
325 index += 1
326 return rows, findings
327
328
329def scan_doxygen_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
330 """Inventory the fatality and exclusion assignments in the root Doxyfile."""
331 if path != "Doxyfile":
332 return [], []
333 assignments, findings = _doxygen_assignments(text)
334 keys = {
335 "WARN_IF_UNDOCUMENTED",
336 "WARN_NO_PARAMDOC",
337 "WARN_AS_ERROR",
338 "EXCLUDE",
339 "EXCLUDE_PATTERNS",
340 }
341 records: list[Suppression] = []
342 for line, key, assigned_values, assigned_reason in assignments:
343 if key not in keys:
344 continue
345 if key.startswith("WARN_"):
346 values = assigned_values[:1]
347 reason = assigned_reason or (
348 "Doxygen warning policy is explicitly configured at repository scope."
349 )
350 else:
351 values = assigned_values
352 reason = assigned_reason or (
353 "Doxygen excludes non-product, generated, test, or vendored documentation inputs."
354 )
355 for value in values:
356 spec = GovernanceSpec(
357 "documentation-control",
358 "doxygen",
359 f"{key}:{value}",
360 key,
361 f"value:{value}",
362 reason,
363 "central-config",
364 )
365 records.append(_record(path, line, spec))
366 return records, findings
367
368
369def scan_security_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
370 """Recognize exact first-party security-analyzer comment directives."""
371 if ownership(path) != "first-party":
372 return [], []
373 comments, lex_findings = extract_comments(path, text)
374 records: list[Suppression] = []
375 for comment in comments:
376 body = comment.text.strip().lstrip("*").strip()
377 for pattern, tool in SECURITY_RULES:
378 match = pattern.fullmatch(body)
379 if match is None:
380 continue
381 groups = match.groupdict()
382 records.append(
383 _record(
384 path,
385 comment.line,
386 GovernanceSpec(
387 "security-analysis-control",
388 tool,
389 groups.get("rule") or "all",
390 body.split()[0],
391 "line",
392 groups.get("reason") or "",
393 "inline-comment",
394 ),
395 )
396 )
397 break
398 findings = [Finding(item.code, item.message, path, item.line) for item in lex_findings]
399 return records, findings
400
401
402def scan_other_language_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
403 """Recognize exact Java/Kotlin/Rust/Go suppression syntax in matching files."""
404 spec = OTHER_LANGUAGE_RULES.get(Path(path).suffix.lower())
405 if spec is None or ownership(path) != "first-party":
406 return [], []
407 tool, pattern = spec
408 records: list[Suppression] = []
409 for line, raw in enumerate(text.splitlines(), start=1):
410 match = pattern.fullmatch(raw.strip())
411 if match is None:
412 continue
413 groups = match.groupdict()
414 records.append(
415 _record(
416 path,
417 line,
418 GovernanceSpec(
419 "other-language-control",
420 tool,
421 groups["rule"],
422 groups.get("kind") or "suppress",
423 "declaration",
424 groups.get("reason") or "",
425 "language-syntax",
426 ),
427 )
428 )
429 return records, []
430
431
432def scan_governance_file(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
433 """Run every typed per-file governance parser."""
434 records: list[Suppression] = []
435 findings: list[Finding] = []
436 for parser in (
437 scan_ansible_lint_config,
438 scan_registered_global_exclusions,
439 scan_gitignore_exemptions,
440 scan_doxygen_controls,
441 scan_security_controls,
442 scan_other_language_controls,
443 ):
444 found, problems = parser(path, text)
445 records.extend(found)
446 findings.extend(problems)
447 return records, findings