ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_shell_scan.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Syntax-aware inventory for shell status and global ShellCheck controls."""
4
5from __future__ import annotations
6
7import re
8from dataclasses import replace
9from pathlib import Path
10
11from suppression_catalog import (
12 SHELL_STATUS_MASK_RE,
13 SHELLCHECK_GLOBAL_EXCLUDE_RE,
14 is_shell_control,
15 ownership,
16)
17from suppression_hash_lex import HashLexLine, hash_lines
18from suppression_model import Finding, Suppression
19from suppression_shell_lex import ShellOperatorState, mask_shell_operators
20
21SHELLCHECK_OPTS_ASSIGN_RE = re.compile(
22 r"^\s*(?:(?:export|readonly)\s+)?SHELLCHECK_OPTS\s*\+?=\s*(?P<value>.*)$"
23)
24MIN_QUOTED_VALUE_LENGTH = 2
25
26
27def _shell_status_line_records(path: str, line: HashLexLine, code: str) -> list[Suppression]:
28 """Return active local status masks from one syntax-masked shell line."""
29 return [
30 Suppression(
31 path,
32 line.line,
33 match.start() + 1,
34 "shell-status",
35 "shell",
36 "ignored-status",
37 match.group(0),
38 "command-list",
39 line.comment.strip(),
40 "active-shell-syntax",
41 ownership(path),
42 )
43 for match in SHELL_STATUS_MASK_RE.finditer(code)
44 ]
45
46
47def _global_concerns(rule: str, reason: str) -> tuple[str, ...]:
48 """Return concerns for one repository-wide ShellCheck exclusion."""
49 concerns: list[str] = []
50 if rule == "*":
51 concerns.append("broad-rule")
52 if not reason:
53 concerns.append("blank-reason")
54 return tuple(concerns)
55
56
57def _shellcheck_options_records(path: str, line: HashLexLine) -> list[Suppression]:
58 """Return active repository-wide exclusions passed through SHELLCHECK_OPTS."""
59 assignment = SHELLCHECK_OPTS_ASSIGN_RE.match(line.code)
60 if assignment is None:
61 return []
62 raw_value = assignment.group("value")
63 leading = len(raw_value) - len(raw_value.lstrip())
64 value = raw_value.strip()
65 value_column = assignment.start("value") + leading + 1
66 if len(value) >= MIN_QUOTED_VALUE_LENGTH and value[0] == value[-1] and value[0] in {'"', "'"}:
67 value = value[1:-1]
68 value_column += 1
69 records: list[Suppression] = []
70 reason = line.comment.strip()
71 for match in SHELLCHECK_GLOBAL_EXCLUDE_RE.finditer(value):
72 rules = (item.strip() for item in match.group("rules").split(","))
73 records.extend(
74 Suppression(
75 path,
76 line.line,
77 value_column + match.start(),
78 "shellcheck",
79 "shellcheck",
80 rule,
81 "SHELLCHECK_OPTS exclude",
82 "repository",
83 reason,
84 "active-shell-syntax",
85 ownership(path),
86 _global_concerns(rule, reason),
87 )
88 for rule in rules
89 )
90 return records
91
92
93def _shellcheckrc_records(path: str, lines: list[HashLexLine]) -> list[Suppression]:
94 """Return source-located exclusions from the central ShellCheck config."""
95 records: list[Suppression] = []
96 for line in lines:
97 stripped = line.code.strip()
98 if not stripped.lower().startswith("exclude="):
99 continue
100 reason = line.comment.strip()
101 records.extend(
102 Suppression(
103 path,
104 line.line,
105 1,
106 "shellcheck",
107 "shellcheck",
108 rule.strip(),
109 ".shellcheckrc exclude",
110 "repository",
111 reason,
112 "central-config",
113 ownership(path),
114 _global_concerns(rule.strip(), reason),
115 )
116 for rule in stripped.split("=", 1)[1].split(",")
117 )
118 return records
119
120
121def _embedded_shell_status_records(
122 path: str,
123 text: str,
124 lines: list[HashLexLine],
125 active: list[Suppression],
126) -> list[Suppression]:
127 """Inventory status masks inside shell strings or heredoc payloads."""
128 active_columns: dict[int, set[int]] = {}
129 for record in active:
130 active_columns.setdefault(record.line, set()).add(record.column)
131 raw_lines = text.splitlines()
132 records: list[Suppression] = []
133 for line in lines:
134 raw = raw_lines[line.line - 1]
135 source = line.code
136 if not source and raw.lstrip().startswith("#"):
137 continue
138 if not source:
139 source = raw
140 for match in SHELL_STATUS_MASK_RE.finditer(source):
141 column = match.start() + 1
142 if column in active_columns.get(line.line, set()):
143 continue
144 records.append(
145 Suppression(
146 path,
147 line.line,
148 column,
149 "shell-status",
150 "shell",
151 "ignored-status",
152 match.group(0),
153 "embedded-shell-or-heredoc",
154 line.comment.strip(),
155 "embedded-text-audit",
156 ownership(path),
157 )
158 )
159 return records
160
161
162def _active_shell_status_records(
163 path: str,
164 lines: list[HashLexLine],
165 *,
166 include_shellcheck_options: bool = False,
167) -> list[Suppression]:
168 """Inventory direct and multiline status masks in executable shell code."""
169 records: list[Suppression] = []
170 state = ShellOperatorState()
171 pending: Suppression | None = None
172 for line in lines:
173 active_code = mask_shell_operators(line.code, state)
174 if pending is not None and active_code.strip():
175 if re.match(r"^\s*(?:true\b|:(?![A-Za-z0-9_]))", active_code):
176 records.append(pending)
177 pending = None
178 records.extend(_shell_status_line_records(path, line, active_code))
179 if include_shellcheck_options:
180 records.extend(_shellcheck_options_records(path, line))
181 operator = re.search(r"\|\|\s*$", active_code)
182 if operator is not None:
183 pending = Suppression(
184 path,
185 line.line,
186 operator.start() + 1,
187 "shell-status",
188 "shell",
189 "ignored-status",
190 "|| true",
191 "command-list",
192 line.comment.strip(),
193 "active-shell-syntax",
194 ownership(path),
195 )
196 return records
197
198
199def shell_status_records(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
200 """Inventory active shell status masks and global ShellCheck exclusions.
201
202 Runtime status masks are behavior rather than analyzer waivers, so source
203 comments are optional; every occurrence remains an explicit manual-review
204 row even when its ``reason`` is blank.
205 """
206 first_line = text.partition("\n")[0]
207 shell_source = is_shell_control(path, first_line)
208 if not shell_source and path != ".shellcheckrc":
209 return [], []
210 lines, findings = hash_lines(path, text)
211 if not shell_source:
212 return _shellcheckrc_records(path, lines), findings
213 records = _active_shell_status_records(path, lines, include_shellcheck_options=True)
214 records.extend(_embedded_shell_status_records(path, text, lines, records))
215 return records, findings
216
217
218YAML_BLOCK_HEADER_RE = re.compile(
219 r"^(?P<indent> *)(?:-\s+)?(?P<key>[A-Za-z0-9_.-]+):\s*[>|]"
220 r"(?:[+-]?[1-9]?|[1-9][+-]?)?\s*(?:#.*)?$"
221)
222
223
224def yaml_shell_block_status_records(
225 path: str, text: str
226) -> tuple[list[Suppression], list[Finding]]:
227 """Inventory shell masks in executable YAML block scalars.
228
229 Workflow ``run`` and Ansible ``shell`` blocks are executable by definition.
230 Other block keys, such as ``copy.content``, are scanned only when their first
231 nonblank payload line is a shell shebang.
232 """
233 if Path(path).suffix.lower() not in {".yaml", ".yml"}:
234 return [], []
235 raw_lines = text.splitlines()
236 records: list[Suppression] = []
237 findings: list[Finding] = []
238 index = 0
239 while index < len(raw_lines):
240 header = YAML_BLOCK_HEADER_RE.match(raw_lines[index])
241 if header is None:
242 index += 1
243 continue
244 header_indent = len(header.group("indent"))
245 end = index + 1
246 while end < len(raw_lines):
247 raw = raw_lines[end]
248 indent = len(raw) - len(raw.lstrip(" "))
249 if raw.strip() and indent <= header_indent:
250 break
251 end += 1
252 payload = raw_lines[index + 1 : end]
253 nonblank = [raw for raw in payload if raw.strip()]
254 if not nonblank:
255 index = end
256 continue
257 content_indent = min(len(raw) - len(raw.lstrip(" ")) for raw in nonblank)
258 dedented = [raw[content_indent:] if raw.strip() else "" for raw in payload]
259 first = next(raw.lstrip() for raw in dedented if raw.strip())
260 key = header.group("key").rsplit(".", 1)[-1].lower()
261 shell_payload = key in {"run", "shell"} or (
262 first.startswith("#!") and any(word in first for word in ("sh", "bash", "zsh"))
263 )
264 if not shell_payload:
265 index = end
266 continue
267 block_text = "\n".join(dedented)
268 block_lines, _ = hash_lines("embedded-shell.sh", block_text)
269 active = _active_shell_status_records(path, block_lines)
270 active.extend(_embedded_shell_status_records(path, block_text, block_lines, active))
271 records.extend(
272 replace(
273 record,
274 line=index + 1 + record.line,
275 column=content_indent + record.column,
276 provenance="yaml-shell-block",
277 fingerprint="",
278 )
279 for record in active
280 )
281 index = end
282 return records, findings
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157