3"""Syntax-aware inventory for shell status and global ShellCheck controls."""
5from __future__
import annotations
8from dataclasses
import replace
9from pathlib
import Path
11from suppression_catalog
import (
13 SHELLCHECK_GLOBAL_EXCLUDE_RE,
17from suppression_hash_lex
import HashLexLine, hash_lines
18from suppression_model
import Finding, Suppression
19from suppression_shell_lex
import ShellOperatorState, mask_shell_operators
21SHELLCHECK_OPTS_ASSIGN_RE = re.compile(
22 r"^\s*(?:(?:export|readonly)\s+)?SHELLCHECK_OPTS\s*\+?=\s*(?P<value>.*)$"
24MIN_QUOTED_VALUE_LENGTH = 2
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."""
40 "active-shell-syntax",
43 for match
in SHELL_STATUS_MASK_RE.finditer(code)
47def _global_concerns(rule: str, reason: str) -> tuple[str, ...]:
48 """Return concerns for one repository-wide ShellCheck exclusion."""
49 concerns: list[str] = []
51 concerns.append(
"broad-rule")
53 concerns.append(
"blank-reason")
54 return tuple(concerns)
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:
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 {
'"',
"'"}:
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(
","))
77 value_column + match.start(),
81 "SHELLCHECK_OPTS exclude",
84 "active-shell-syntax",
86 _global_concerns(rule, reason),
93def _shellcheckrc_records(path: str, lines: list[HashLexLine]) -> list[Suppression]:
94 """Return source-located exclusions from the central ShellCheck config."""
95 records: list[Suppression] = []
97 stripped = line.code.strip()
98 if not stripped.lower().startswith(
"exclude="):
100 reason = line.comment.strip()
109 ".shellcheckrc exclude",
114 _global_concerns(rule.strip(), reason),
116 for rule
in stripped.split(
"=", 1)[1].split(
",")
121def _embedded_shell_status_records(
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] = []
134 raw = raw_lines[line.line - 1]
136 if not source
and raw.lstrip().startswith(
"#"):
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()):
153 "embedded-shell-or-heredoc",
154 line.comment.strip(),
155 "embedded-text-audit",
162def _active_shell_status_records(
164 lines: list[HashLexLine],
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
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)
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(
186 operator.start() + 1,
192 line.comment.strip(),
193 "active-shell-syntax",
199def shell_status_records(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
200 """Inventory active shell status masks and global ShellCheck exclusions.
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.
206 first_line = text.partition(
"\n")[0]
207 shell_source = is_shell_control(path, first_line)
208 if not shell_source
and path !=
".shellcheckrc":
210 lines, findings = hash_lines(path, text)
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
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*(?:#.*)?$"
224def yaml_shell_block_status_records(
226) -> tuple[list[Suppression], list[Finding]]:
227 """Inventory shell masks in executable YAML block scalars.
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.
233 if Path(path).suffix.lower()
not in {
".yaml",
".yml"}:
235 raw_lines = text.splitlines()
236 records: list[Suppression] = []
237 findings: list[Finding] = []
239 while index < len(raw_lines):
240 header = YAML_BLOCK_HEADER_RE.match(raw_lines[index])
244 header_indent = len(header.group(
"indent"))
246 while end < len(raw_lines):
248 indent = len(raw) - len(raw.lstrip(
" "))
249 if raw.strip()
and indent <= header_indent:
252 payload = raw_lines[index + 1 : end]
253 nonblank = [raw
for raw
in payload
if raw.strip()]
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"))
264 if not shell_payload:
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))
274 line=index + 1 + record.line,
275 column=content_indent + record.column,
276 provenance=
"yaml-shell-block",
282 return records, findings
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.