ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
shell_invocation_policy.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Structural caller validation for protected and sourced-only shell files.
4
5The typed entry-point table owns which shell files require privileged startup
6and which files may only be sourced. This module owns how executable and
7configuration text may refer to those files. It deliberately parses argv
8structure instead of looking for a few weak Bash spellings.
9"""
10
11from __future__ import annotations
12
13import ast
14import json
15import re
16import shlex
17from collections.abc import Iterable, Mapping
18from dataclasses import dataclass
19
20import shell_invocation_selftest_cases as fixtures
21from shell_entrypoint_policy import (
22 ShellDialect,
23 ShellPolicy,
24 ShellSecurity,
25 ShellUsage,
26)
27from shell_invocation_references import (
28 DOCKER_COMMANDS,
29 EXACT_REFERENCES,
30 JSON_FENCE_LANGUAGES,
31 PROCESS_CALLS,
32 SHELL_FENCE_LANGUAGES,
33 YAML_COMMAND_KEYS,
34)
35
36SHELL_CONTROL = frozenset({";", "&&", "||", "|", "&", "(", ")"})
37SHELL_PREFIX = frozenset({"if", "elif", "while", "until", "then", "do", "!", "{"})
38DECLARATION_PREFIX = frozenset({"declare", "export", "local", "readonly", "typeset"})
39MARKDOWN_FENCE = re.compile(r"^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$")
40PRIVILEGED_TARGET_INDEX = 2
41JUST_RECIPE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*(?:\s+[^:]*)?:\s*$")
42NONCOMMAND_SHELL_PREFIX = frozenset({"[[", "case", "for", "function", "select"})
43RELEASE_LOADER_NAME = "source_release_selftest_helper_from"
44RELEASE_LOADER_MAIN_REL = "scripts/dev/provision_dev_box_toolchain.sh"
45RELEASE_LOADER_MAIN_LOGICAL = (
46 'source_release_selftest_helper_from "$main" "$helper" '
47 '"$expected_dir" "$expected_digest" || return 1'
48)
49RELEASE_LOADER_FIXTURE_REL = "scripts/dev/provision_dev_box_toolchain_selftest.bash"
50RELEASE_LOADER_FIXTURE_LOGICAL = (
51 'source_release_selftest_helper_from "$main" "$helper" "$directory" "$digest" || status=$?'
52)
53RELEASE_LOADER_GRAMMARS = {
54 RELEASE_LOADER_MAIN_REL: (RELEASE_LOADER_MAIN_LOGICAL, ShellUsage.ENTRY, True, False),
55 RELEASE_LOADER_FIXTURE_REL: (
56 RELEASE_LOADER_FIXTURE_LOGICAL,
57 ShellUsage.SOURCED_ONLY,
58 False,
59 True,
60 ),
61}
62
63
64@dataclass(frozen=True)
65class CallerFinding:
66 """One structural caller-policy violation."""
67
68 line: int
69 message: str
70
71
72@dataclass(frozen=True)
73class ShellSegmentContext:
74 """Immutable caller and source context for one logical shell command."""
75
76 variables: Mapping[str, str]
77 policies: Mapping[str, ShellPolicy]
78 caller_policy: ShellPolicy | None
79 rel: str | None
80 logical: str
81 stripped: str
82 number: int
83
84
85def guarded_paths(policies: Mapping[str, ShellPolicy]) -> frozenset[str]:
86 """Return all privileged or source-only paths requiring caller review."""
87 return frozenset(
88 path
89 for path, policy in policies.items()
90 if policy.security is ShellSecurity.PRIVILEGED or policy.usage is ShellUsage.SOURCED_ONLY
91 )
92
93
94def _target_for_token(token: str, policies: Mapping[str, ShellPolicy]) -> str | None:
95 """Resolve an exact or rooted token to one governed repository path."""
96 normalized = token.replace("\\", "/")
97 if normalized.startswith("file://"):
98 normalized = normalized.removeprefix("file://")
99 for target in guarded_paths(policies):
100 if normalized == target or normalized.endswith(f"/{target}"):
101 return target
102 return None
103
104
105def _expand_token(token: str, variables: Mapping[str, str]) -> str:
106 """Resolve the deliberately small scalar shell-dataflow vocabulary."""
107 if token.startswith("${") and token.endswith("}"):
108 return variables.get(token[2:-1], token)
109 if token.startswith("$") and token[1:].isidentifier():
110 return variables.get(token[1:], token)
111 return token
112
113
114def _normalize_shell(logical: str) -> str:
115 """Return stable whitespace for exact reviewed shell references."""
116 return " ".join(logical.split())
117
118
119def _is_exact_reference(rel: str | None, logical: str, target: str | None = None) -> bool:
120 """Return whether an exact governed occurrence has a written non-launch reason."""
121 if rel is None:
122 return False
123 normalized = _normalize_shell(logical)
124 return any(
125 entry.rel == rel
126 and entry.logical == normalized
127 and (target is None or entry.target == target)
128 for entry in EXACT_REFERENCES
129 )
130
131
132def validate_argv(
133 argv: list[str],
134 policies: Mapping[str, ShellPolicy],
135 caller_policy: ShellPolicy | None,
136) -> list[str]:
137 """Validate every governed target in one resolved argument vector."""
138 targets = [(_target_for_token(token, policies), index) for index, token in enumerate(argv)]
139 governed = [(target, index) for target, index in targets if target is not None]
140 findings: list[str] = []
141 for target, index in governed:
142 if target is None:
143 continue
144 policy = policies[target]
145 source_call = bool(argv) and argv[0] in {".", "source"} and index == 1
146 if source_call:
147 if policy.usage not in {ShellUsage.SOURCED_ONLY, ShellUsage.DUAL_USE}:
148 findings.append(f"entry-only target is sourced: {target}")
149 elif policy.source_requires_privileged_parent and (
150 caller_policy is None or caller_policy.security is not ShellSecurity.PRIVILEGED
151 ):
152 findings.append(f"privileged source has no privileged parent: {target}")
153 continue
154 direct = (
155 index == 0
156 and policy.executable
157 and policy.usage
158 in {
159 ShellUsage.ENTRY,
160 ShellUsage.DUAL_USE,
161 }
162 )
163 privileged_argv = (
164 index == PRIVILEGED_TARGET_INDEX
165 and argv[:2] == ["/bin/bash", "-p"]
166 and policy.security is ShellSecurity.PRIVILEGED
167 and policy.usage is not ShellUsage.SOURCED_ONLY
168 )
169 if not direct and not privileged_argv:
170 findings.append(
171 "governed target lacks direct verified shebang or exact "
172 f"/bin/bash -p argv: {target}"
173 )
174 return findings
175
176
177def _logical_shell_lines(text: str) -> list[tuple[int, str]]:
178 """Join shell continuations and omit quoted heredoc payloads."""
179 logicals: list[tuple[int, str]] = []
180 lines = text.splitlines()
181 index = 0
182 heredoc_end: str | None = None
183 while index < len(lines):
184 number = index + 1
185 line = lines[index]
186 index += 1
187 if heredoc_end is not None:
188 if line.strip() == heredoc_end:
189 heredoc_end = None
190 continue
191 logical = line
192 while index < len(lines) and (
193 logical.rstrip().endswith("\\") or logical.rstrip().endswith(("|", "||", "&&"))
194 ):
195 stripped = logical.rstrip()
196 logical = (stripped.removesuffix("\\")) + lines[index]
197 index += 1
198 heredoc = re.search(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1", logical)
199 if heredoc is not None:
200 heredoc_end = heredoc.group(2)
201 logicals.append((number, logical))
202 return logicals
203
204
205def _shell_tokens(logical: str) -> list[str] | None:
206 """Decode one shell logical line, preserving command separators."""
207 lexer = shlex.shlex(logical, posix=True, punctuation_chars=";&|()")
208 lexer.commenters = "#"
209 lexer.whitespace_split = True
210 try:
211 return list(lexer)
212 except ValueError:
213 return None
214
215
216def _command_segments(tokens: list[str]) -> Iterable[list[str]]:
217 """Yield argv-like shell command segments."""
218 segment: list[str] = []
219 for token in tokens:
220 if token in SHELL_CONTROL:
221 if segment:
222 yield segment
223 segment = []
224 else:
225 segment.append(token)
226 if segment:
227 yield segment
228
229
230def _env_command_index(arguments: list[str]) -> int:
231 """Return the nested executable index for fixed env-style argv."""
232 index = 1
233 while index < len(arguments):
234 argument = arguments[index]
235 if argument in {"-u", "--unset"}:
236 index += 2
237 continue
238 if argument.startswith("-") or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", argument):
239 index += 1
240 continue
241 break
242 return index
243
244
245def _xargs_command_index(arguments: list[str]) -> int:
246 """Return the nested executable index for the supported xargs option grammar."""
247 index = 1
248 options_with_value = {"-a", "-E", "-I", "-L", "-n", "-P", "-s"}
249 while index < len(arguments):
250 argument = arguments[index]
251 if argument == "-" * 2:
252 return index + 1
253 if argument in options_with_value:
254 index += 2
255 elif argument.startswith(("-I", "-E", "-L", "-n", "-P", "-s", "-")):
256 index += 1
257 else:
258 break
259 return index
260
261
262def _nested_command_index(arguments: list[str]) -> int:
263 """Return the actual executable index through reviewed command wrappers."""
264 if arguments and arguments[0] in {"env", "/usr/bin/env"}:
265 return _env_command_index(arguments)
266 if arguments and arguments[0] in {"xargs", "/usr/bin/xargs"}:
267 return _xargs_command_index(arguments)
268 return 0
269
270
271def _resolved_shell_argv(
272 segment: list[str],
273 variables: dict[str, str],
274 policies: Mapping[str, ShellPolicy],
275) -> tuple[list[str], bool]:
276 """Resolve leading declarations and scalar command indirection."""
277 tokens = list(segment)
278 while tokens and tokens[0] in SHELL_PREFIX:
279 tokens.pop(0)
280 if tokens and tokens[0].startswith("@"):
281 tokens[0] = tokens[0][1:]
282 if not tokens or tokens[0] in NONCOMMAND_SHELL_PREFIX or tokens[0] in {"}", "]]"}:
283 return [], False
284 declaration = bool(tokens and tokens[0] in DECLARATION_PREFIX)
285 if declaration:
286 tokens.pop(0)
287 while tokens and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[0]):
288 name, value = tokens.pop(0).split("=", 1)
289 variables[name] = _expand_token(value, variables)
290 if declaration:
291 return [], False
292 active = [token for token in tokens if token not in {"then", "do", "}"}]
293 if active and active[0] == "exec":
294 active.pop(0)
295 command_index = _nested_command_index(active)
296 expanded = [_expand_token(token, variables) for token in active]
297 indirect_interpreter = (
298 command_index < len(active)
299 and active[command_index].startswith("$")
300 and expanded[command_index] == "/bin/bash"
301 )
302 indirect_target = any(
303 token.startswith("$")
304 and "/" not in token
305 and _target_for_token(value, policies) is not None
306 for index, (token, value) in enumerate(zip(active, expanded, strict=True))
307 if index != command_index
308 )
309 return expanded[command_index:], indirect_interpreter or indirect_target
310
311
312def _is_authenticated_release_loader(
313 rel: str | None,
314 caller_policy: ShellPolicy | None,
315 logical: str,
316 segment: list[str],
317) -> bool:
318 """Recognize either exact policy-bound release-helper source call."""
319 grammar = RELEASE_LOADER_GRAMMARS.get(rel or "")
320 if grammar is None:
321 return False
322 expected, usage, executable, privileged_parent = grammar
323 expected_argv = tuple(shlex.split(expected.split(" || ", 1)[0]))
324 return (
325 caller_policy is not None
326 and caller_policy.security is ShellSecurity.PRIVILEGED
327 and caller_policy.usage is usage
328 and caller_policy.dialect is ShellDialect.BASH
329 and caller_policy.executable is executable
330 and caller_policy.source_requires_privileged_parent is privileged_parent
331 and logical.strip() == expected
332 and tuple(segment) == expected_argv
333 )
334
335
336def _scan_shell_segment(
337 segment: list[str],
338 context: ShellSegmentContext,
339) -> tuple[list[CallerFinding], int]:
340 """Validate one shell command segment and count an accepted loader call."""
341 is_loader_call = segment and segment[0] == RELEASE_LOADER_NAME
342 is_loader_definition = context.stripped == f"{RELEASE_LOADER_NAME}() {{"
343 if is_loader_call and not is_loader_definition:
344 accepted = _is_authenticated_release_loader(
345 context.rel,
346 context.caller_policy,
347 context.logical,
348 segment,
349 )
350 findings = (
351 []
352 if accepted
353 else [CallerFinding(context.number, "authenticated release loader drifted")]
354 )
355 return findings, int(accepted)
356 argv, indirect = _resolved_shell_argv(segment, context.variables, context.policies)
357 if indirect:
358 if _is_exact_reference(context.rel, context.logical):
359 return [], 0
360 return [
361 CallerFinding(context.number, "variable-indirect protected interpreter or target")
362 ], 0
363 messages = validate_argv(argv, context.policies, context.caller_policy)
364 targets = {target for token in argv if (target := _target_for_token(token, context.policies))}
365 if (
366 messages
367 and targets
368 and all(_is_exact_reference(context.rel, context.logical, target) for target in targets)
369 ):
370 return [], 0
371 return [CallerFinding(context.number, message) for message in messages], 0
372
373
374def scan_shell_text(
375 text: str,
376 policies: Mapping[str, ShellPolicy],
377 caller_policy: ShellPolicy | None,
378 rel: str | None = None,
379) -> list[CallerFinding]:
380 """Structurally validate shell logical commands."""
381 findings: list[CallerFinding] = []
382 variables: dict[str, str] = {}
383 governed = guarded_paths(policies)
384 array_depth = 0
385 release_loader_calls = 0
386 for number, logical in _logical_shell_lines(text):
387 stripped = logical.strip()
388 if array_depth:
389 array_depth += stripped.count("(") - stripped.count(")")
390 continue
391 if re.match(
392 r"^(?:declare\s+-a\s+|local\s+-a\s+)?[A-Za-z_][A-Za-z0-9_]*=\‍($",
393 stripped,
394 ):
395 array_depth = 1
396 continue
397 tokens = _shell_tokens(logical)
398 if tokens is None:
399 targets = [target for target in governed if target in logical]
400 if targets and not all(_is_exact_reference(rel, logical, target) for target in targets):
401 findings.append(CallerFinding(number, "cannot parse governed shell occurrence"))
402 continue
403 if tokens and tokens[0] in NONCOMMAND_SHELL_PREFIX:
404 continue
405 context = ShellSegmentContext(
406 variables,
407 policies,
408 caller_policy,
409 rel,
410 logical,
411 stripped,
412 number,
413 )
414 for segment in _command_segments(tokens):
415 segment_findings, accepted_calls = _scan_shell_segment(segment, context)
416 findings.extend(segment_findings)
417 release_loader_calls += accepted_calls
418 findings.extend(
419 ()
420 if rel not in RELEASE_LOADER_GRAMMARS or release_loader_calls == 1
421 else (CallerFinding(0, "authenticated release loader count drifted"),)
422 )
423 return findings
424
425
426def _call_name(call: ast.Call) -> str:
427 """Return a dotted static call name."""
428 parts: list[str] = []
429 node: ast.expr = call.func
430 while isinstance(node, ast.Attribute):
431 parts.append(node.attr)
432 node = node.value
433 if isinstance(node, ast.Name):
434 parts.append(node.id)
435 return ".".join(reversed(parts))
436
437
438def _string_expr(node: ast.AST, values: Mapping[str, object]) -> str | None:
439 """Resolve a bounded static/path-like Python string expression."""
440 result: str | None = None
441 if isinstance(node, ast.Constant) and isinstance(node.value, str):
442 result = node.value
443 elif isinstance(node, ast.Name):
444 value = values.get(node.id)
445 result = value if isinstance(value, str) else None
446 elif isinstance(node, ast.JoinedStr):
447 pieces: list[str] = []
448 for value in node.values:
449 if isinstance(value, ast.Constant) and isinstance(value.value, str):
450 pieces.append(value.value)
451 else:
452 pieces.append("*")
453 result = "".join(pieces)
454 elif isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Div)):
455 left = _string_expr(node.left, values)
456 right = _string_expr(node.right, values)
457 if left is None:
458 left = "*"
459 if right is not None:
460 separator = "/" if isinstance(node.op, ast.Div) else ""
461 result = f"{left.rstrip('/')}{separator}{right.lstrip('/')}"
462 elif (
463 isinstance(node, ast.Call)
464 and isinstance(node.func, ast.Name)
465 and node.func.id in {"Path", "PurePath", "str"}
466 and node.args
467 ):
468 result = _string_expr(node.args[0], values)
469 return result
470
471
472def _argv_expr(node: ast.AST, values: Mapping[str, object]) -> list[str] | None:
473 """Resolve a bounded literal/list Python argv expression."""
474 if isinstance(node, ast.Name):
475 value = values.get(node.id)
476 return list(value) if isinstance(value, tuple) else None
477 if isinstance(node, (ast.List, ast.Tuple)):
478 argv: list[str] = []
479 for element in node.elts:
480 if isinstance(element, ast.Starred):
481 nested = _argv_expr(element.value, values)
482 if nested is None:
483 return None
484 argv.extend(nested)
485 continue
486 value = _string_expr(element, values)
487 if value is None:
488 return None
489 argv.append(value)
490 return argv
491 if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
492 left = _argv_expr(node.left, values)
493 right = _argv_expr(node.right, values)
494 return None if left is None or right is None else left + right
495 return None
496
497
498class _PythonCallerVisitor(ast.NodeVisitor):
499 """Track bounded argv dataflow into process-creation APIs."""
500
501 def __init__(self, policies: Mapping[str, ShellPolicy]) -> None:
502 self.policies = policies
503 self.values: dict[str, object] = {}
504 self.findings: list[CallerFinding] = []
505
506 def visit_Assign(self, node: ast.Assign) -> None:
507 """Remember simple string and argv assignments."""
508 string = _string_expr(node.value, self.values)
509 argv = _argv_expr(node.value, self.values)
510 value: object | None = tuple(argv) if argv is not None else string
511 for target in node.targets:
512 if isinstance(target, ast.Name) and value is not None:
513 self.values[target.id] = value
514 self.generic_visit(node)
515
516 def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
517 """Remember simple annotated assignments."""
518 if isinstance(node.target, ast.Name) and node.value is not None:
519 string = _string_expr(node.value, self.values)
520 argv = _argv_expr(node.value, self.values)
521 value: object | None = tuple(argv) if argv is not None else string
522 if value is not None:
523 self.values[node.target.id] = value
524 self.generic_visit(node)
525
526 def visit_Call(self, node: ast.Call) -> None:
527 """Validate bounded argv passed to process-creation APIs."""
528 name = _call_name(node)
529 if name in PROCESS_CALLS and node.args:
530 argv = _argv_expr(node.args[0], self.values)
531 if argv is None and name.endswith("create_subprocess_exec"):
532 argv = [
533 value
534 for argument in node.args
535 if (value := _string_expr(argument, self.values)) is not None
536 ]
537 if len(argv) != len(node.args):
538 argv = None
539 governed_literals = {
540 target
541 for child in ast.walk(node)
542 if isinstance(child, ast.Constant) and isinstance(child.value, str)
543 for target in guarded_paths(self.policies)
544 if target in child.value
545 }
546 if argv is None:
547 if governed_literals:
548 self.findings.append(
549 CallerFinding(node.lineno, "governed Python process argv is unresolved")
550 )
551 else:
552 for message in validate_argv(argv, self.policies, None):
553 self.findings.append(CallerFinding(node.lineno, message))
554 self.generic_visit(node)
555
556
557def scan_python_text(text: str, policies: Mapping[str, ShellPolicy]) -> list[CallerFinding]:
558 """Parse Python and validate process argv with bounded dataflow."""
559 try:
560 tree = ast.parse(text)
561 except SyntaxError as exc:
562 if any(target in text for target in guarded_paths(policies)):
563 return [CallerFinding(exc.lineno or 1, "cannot parse governed Python occurrence")]
564 return []
565 visitor = _PythonCallerVisitor(policies)
566 visitor.visit(tree)
567 return visitor.findings
568
569
570def _json_argv_findings(
571 value: object,
572 policies: Mapping[str, ShellPolicy],
573 line: int = 1,
574) -> list[CallerFinding]:
575 """Validate command/args objects and argv arrays recursively."""
576 findings: list[CallerFinding] = []
577 if isinstance(value, dict):
578 command = value.get("command")
579 args = value.get("args")
580 grouped = isinstance(command, str) and isinstance(args, list)
581 if grouped:
582 argv = [command, *(item for item in args if isinstance(item, str))]
583 if len(argv) != len(args) + 1:
584 findings.append(CallerFinding(line, "JSON command argv contains non-string data"))
585 else:
586 findings.extend(
587 CallerFinding(line, message) for message in validate_argv(argv, policies, None)
588 )
589 for key, nested in value.items():
590 if grouped and key in {"command", "args"}:
591 continue
592 findings.extend(_json_argv_findings(nested, policies, line))
593 elif isinstance(value, list):
594 if all(isinstance(item, str) for item in value):
595 argv = list(value)
596 if any(_target_for_token(item, policies) is not None for item in argv):
597 findings.extend(
598 CallerFinding(line, message) for message in validate_argv(argv, policies, None)
599 )
600 for nested in value:
601 findings.extend(_json_argv_findings(nested, policies, line))
602 return findings
603
604
605def scan_json_text(text: str, policies: Mapping[str, ShellPolicy]) -> list[CallerFinding]:
606 """Parse JSON command structures and argv arrays."""
607 try:
608 value = json.loads(text)
609 except json.JSONDecodeError as exc:
610 if any(target in text for target in guarded_paths(policies)):
611 return [CallerFinding(exc.lineno, "cannot parse governed JSON occurrence")]
612 return []
613 return _json_argv_findings(value, policies)
614
615
616def scan_markdown_text(text: str, policies: Mapping[str, ShellPolicy]) -> list[CallerFinding]:
617 """Validate executable shell and JSON fenced blocks; prose stays prose."""
618 findings: list[CallerFinding] = []
619 lines = text.splitlines()
620 index = 0
621 while index < len(lines):
622 match = MARKDOWN_FENCE.match(lines[index])
623 if match is None:
624 index += 1
625 continue
626 fence, language = match.groups()
627 start = index + 2
628 index += 1
629 payload: list[str] = []
630 while index < len(lines) and not lines[index].lstrip().startswith(fence):
631 payload.append(lines[index])
632 index += 1
633 block = "\n".join(payload) + "\n"
634 if language in SHELL_FENCE_LANGUAGES:
635 normalized = "\n".join(line.removeprefix("$ ").removeprefix("> ") for line in payload)
636 block_findings = scan_shell_text(normalized, policies, None)
637 elif language in JSON_FENCE_LANGUAGES:
638 block_findings = scan_json_text(block, policies)
639 else:
640 block_findings = []
641 findings.extend(
642 CallerFinding(start + finding.line - 1, finding.message) for finding in block_findings
643 )
644 index += 1
645 return findings
646
647
648def scan_just_text(text: str, policies: Mapping[str, ShellPolicy]) -> list[CallerFinding]:
649 """Validate recipe bodies and preserve their exact interpreter context."""
650 findings: list[CallerFinding] = []
651 lines = text.splitlines()
652 clean_prefixes = {
653 match.group(1): "/usr/bin/env -i"
654 for line in lines
655 if (
656 match := re.match(
657 r'^([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*"/usr/bin/env -i(?:\s|\")',
658 line,
659 )
660 )
661 }
662 index = 0
663 while index < len(lines):
664 if not JUST_RECIPE.match(lines[index]):
665 index += 1
666 continue
667 index += 1
668 body_start = index + 1
669 body: list[str] = []
670 while index < len(lines) and (not lines[index] or lines[index][0].isspace()):
671 body_line = lines[index].lstrip()
672 for name, prefix in clean_prefixes.items():
673 body_line = body_line.replace(f"{{{{ {name} }}}}", prefix)
674 body.append(body_line)
675 index += 1
676 active = [
677 line for line in body if line and (line == "#!/bin/bash -p" or not line.startswith("#"))
678 ]
679 caller_policy = None
680 if active and active[0] == "#!/bin/bash -p":
681 caller_policy = ShellPolicy(
682 ShellSecurity.PRIVILEGED,
683 ShellUsage.ENTRY,
684 ShellDialect.BASH,
685 executable=False,
686 source_requires_privileged_parent=False,
687 )
688 findings.extend(
689 CallerFinding(body_start + finding.line - 1, finding.message)
690 for finding in scan_shell_text("\n".join(body) + "\n", policies, caller_policy)
691 )
692 return findings
693
694
695def scan_yaml_text(text: str, policies: Mapping[str, ShellPolicy]) -> list[CallerFinding]:
696 """Validate YAML command scalars and block bodies without accepting data keys."""
697 findings: list[CallerFinding] = []
698 lines = text.splitlines()
699 block_indent: int | None = None
700 block_folded = False
701 block: list[str] = []
702 block_start = 1
703
704 def normalize_templates(command: str) -> str:
705 """Resolve only quoted literal governed paths in Ansible expressions."""
706 normalized = command
707 for target in guarded_paths(policies):
708 pattern = (
709 r"\{\{\s*\‍([A-Za-z_][A-Za-z0-9_]*\s*~\s*"
710 rf"(['\"])/?{re.escape(target)}\1\‍)\s*\|\s*quote\s*\}}\}}"
711 )
712 normalized = re.sub(pattern, target, normalized)
713 return normalized
714
715 def flush() -> None:
716 if not block:
717 return
718 command = " ".join(line.strip() for line in block) if block_folded else "\n".join(block)
719 command = normalize_templates(command)
720 findings.extend(
721 CallerFinding(block_start + finding.line - 1, finding.message)
722 for finding in scan_shell_text(command + "\n", policies, None)
723 )
724 block.clear()
725
726 for number, line in enumerate(lines, start=1):
727 indent = len(line) - len(line.lstrip())
728 if block_indent is not None:
729 if line.strip() and indent <= block_indent:
730 flush()
731 block_indent = None
732 else:
733 block.append(line[block_indent + 1 :] if len(line) > block_indent else "")
734 continue
735 match = re.match(r"^\s*(?:-\s*)?([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$", line)
736 if match is None or match.group(1) not in YAML_COMMAND_KEYS:
737 continue
738 value = match.group(2).strip()
739 if value in {"|", ">", "|-", ">-"}:
740 block_indent = indent
741 block_folded = value.startswith(">")
742 block_start = number + 1
743 elif value:
744 findings.extend(
745 CallerFinding(number, finding.message)
746 for finding in scan_shell_text(normalize_templates(value), policies, None)
747 )
748 flush()
749 return findings
750
751
752def scan_dockerfile_text(text: str, policies: Mapping[str, ShellPolicy]) -> list[CallerFinding]:
753 """Validate Docker RUN shell commands and JSON-form CMD/ENTRYPOINT argv."""
754 findings: list[CallerFinding] = []
755 for number, logical in _logical_shell_lines(text):
756 match = re.match(r"^\s*([A-Za-z]+)\s+(.+)$", logical)
757 if match is None or match.group(1).upper() not in DOCKER_COMMANDS:
758 continue
759 command, value = match.group(1).upper(), match.group(2).strip()
760 if command in {"CMD", "ENTRYPOINT"} and value.startswith("["):
761 findings.extend(
762 CallerFinding(number, finding.message)
763 for finding in scan_json_text(value, policies)
764 )
765 else:
766 findings.extend(
767 CallerFinding(number, finding.message)
768 for finding in scan_shell_text(value, policies, None)
769 )
770 return findings
771
772
773def scan_caller_text(
774 rel: str,
775 text: str,
776 policies: Mapping[str, ShellPolicy],
777) -> list[CallerFinding]:
778 """Dispatch one first-party executable/configuration text surface."""
779 findings: list[CallerFinding]
780 caller_policy = policies.get(rel)
781 if rel.endswith(".just") or rel == "justfile":
782 findings = scan_just_text(text, policies)
783 elif caller_policy is not None:
784 findings = scan_shell_text(text, policies, caller_policy, rel)
785 elif rel.endswith(".py"):
786 findings = scan_python_text(text, policies)
787 elif rel.endswith(".json"):
788 findings = scan_json_text(text, policies)
789 elif rel.endswith(".md"):
790 findings = scan_markdown_text(text, policies)
791 elif rel.endswith((".yml", ".yaml")):
792 findings = scan_yaml_text(text, policies)
793 elif rel.endswith("Dockerfile") or rel.rsplit("/", 1)[-1] == "Dockerfile":
794 findings = scan_dockerfile_text(text, policies)
795 elif rel == ".env.example":
796 findings = scan_shell_text(text, policies, None)
797 else:
798 findings = []
799 return findings
800
801
802def _static_reference_selftest_failures(policies: dict[str, ShellPolicy]) -> list[str]:
803 """Exercise privileged source edges and the exact syntax-only reference."""
804 sourced = fixtures.SOURCED
805 failures = [
806 "privileged source parent rejected"
807 for prefix in ("source ", ". $ROOT/")
808 if scan_shell_text(f"{prefix}{sourced}\n", policies, fixtures.PRIVILEGED_CALLER)
809 ]
810 exact_static = "/bin/bash -p -n scripts/dev/agent_workspace.sh\n"
811 static_policy = {
812 **policies,
813 "scripts/dev/agent_workspace.sh": ShellPolicy(
814 ShellSecurity.PRIVILEGED,
815 ShellUsage.ENTRY,
816 ShellDialect.BASH,
817 executable=True,
818 source_requires_privileged_parent=False,
819 ),
820 }
821 if scan_shell_text(exact_static, static_policy, None, "scripts/ci/gates/tests.sh"):
822 failures.append("exact reasoned static syntax reference was rejected")
823 if not scan_shell_text(
824 exact_static.replace(" -n ", " -n -O extglob "),
825 static_policy,
826 None,
827 "scripts/ci/gates/tests.sh",
828 ):
829 failures.append("mutated static syntax reference was accepted")
830 return failures
831
832
833def selftest_failures() -> tuple[list[str], int]:
834 """Replay the complete caller-bypass class in inert fixtures."""
835 policies = fixtures.POLICIES
836 shell_cases = fixtures.SHELL_CASES
837 failures = [
838 label
839 for text, expected, label in shell_cases
840 if len(scan_shell_text(text, policies, None)) != expected
841 ]
842 failures.extend(_static_reference_selftest_failures(policies))
843 loader_cases = fixtures.RELEASE_LOADER_CASES
844 failures.extend(
845 label
846 for rel, caller, text, expect_finding, label in loader_cases
847 if bool(scan_shell_text(f"{text}\n", policies, caller, rel)) != expect_finding
848 )
849 python_cases = fixtures.PYTHON_CASES
850 failures.extend(
851 label
852 for text, expected, label in python_cases
853 if len(scan_python_text(text, policies)) != expected
854 )
855 surface_scanners = {
856 "json": scan_json_text,
857 "yaml": scan_yaml_text,
858 "docker": scan_dockerfile_text,
859 "markdown": scan_markdown_text,
860 }
861 surface_cases = fixtures.SURFACE_CASES
862 failures.extend(
863 label
864 for text, scanner, expected, label in surface_cases
865 if len(surface_scanners[scanner](text, policies)) != expected
866 )
867 just_cases = fixtures.JUST_CASES
868 failures.extend(
869 label
870 for text, expected, label in just_cases
871 if len(scan_just_text(text, policies)) != expected
872 )
873 return (
874 failures,
875 len(shell_cases)
876 + 4
877 + len(loader_cases)
878 + len(python_cases)
879 + len(surface_cases)
880 + len(just_cases),
881 )