#!/usr/bin/env python3 """Fail closed when GitHub Actions trust inputs become mutable or implicit.""" from __future__ import annotations import argparse import re import sys from dataclasses import dataclass from pathlib import Path def _yaml_key(name: str) -> str: """Return a pattern for a plain or simply quoted YAML mapping key.""" escaped = re.escape(name) return rf'(?:{escaped}|"{escaped}"|\'{escaped}\')' ACTION_SHA_RE = re.compile(r"^[0-9a-f]{40}$") CONTAINER_DIGEST_RE = re.compile(r"^docker://.+@sha256:[0-9a-f]{64}$") HOSTED_LATEST_RE = re.compile(r"\b(?:ubuntu|windows|macos)-latest\b") USES_RE = re.compile( rf"^\s*(?:-\s*)?{_yaml_key('uses')}\s*:\s*([^\s#]+)" ) RUN_RE = re.compile(rf"^(\s*)(?:-\s*)?{_yaml_key('run')}\s*:\s*(.*)$") ENV_RE = re.compile(rf"^(\s*)(?:-\s*)?{_yaml_key('env')}\s*:\s*$") ENV_ENTRY_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$") EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}") GITHUB_COMMAND_FILE_RE = re.compile(r"\bGITHUB_(?:ENV|OUTPUT|PATH|STATE)\b") SHELL_ASSIGNMENT_RE = re.compile( r"^\s*(?:(?:export|local|readonly)\s+|declare(?:\s+-[A-Za-z]+)?\s+)?" r"([A-Za-z_][A-Za-z0-9_]*)\s*=" ) POWERSHELL_ASSIGNMENT_RE = re.compile( r"^\s*(?:\[[^\]\r\n]+\]\s*)?\$([A-Za-z_][A-Za-z0-9_]*)\s*=" ) # A trusted reassignment only clears possible taint when it is guaranteed to # execute. Assignments inside these Bash compound commands affect one branch or # iteration, so a later command can still observe the original workflow value. BASH_CONTROL_OPEN_RE = re.compile( r"^\s*(?:if|case|for|select|while|until)\b" ) BASH_CONTROL_CLOSE_RE = re.compile(r"^\s*(?:fi|esac|done)\b") # Workflow-call and dispatch inputs are data, not shell source. The legacy # github.event.inputs alias is identical data, and repository_dispatch callers # fully control client_payload. Step and job outputs are data too: they can # carry event or input values across an otherwise-safe intermediate step. # Whole github contexts are unsafe because they include event data (and the # github context includes github.token). Secrets include github.token because # Actions makes that credential available independently of an explicit # secrets.GITHUB_TOKEN reference. SHELL_DATA_CONTEXT_RE = re.compile( r"(? str: return f"{self.path}:{self.line}: {self.message}" def _indent(line: str) -> int: return len(line) - len(line.lstrip()) def _checkout_block(lines: list[str], uses_index: int) -> list[tuple[int, str]]: """Return lines belonging to the checkout step after its uses declaration.""" uses_indent = _indent(lines[uses_index]) block: list[tuple[int, str]] = [] for index in range(uses_index + 1, len(lines)): line = lines[index] stripped = line.strip() if stripped and ( _indent(line) < uses_indent or (_indent(line) == uses_indent and stripped.startswith("- ")) ): break block.append((index, line)) return block def _run_script_lines(lines: list[str], run_index: int) -> list[tuple[int, str]]: """Return the source lines GitHub will materialize as a run script.""" match = RUN_RE.match(lines[run_index]) if not match: return [] run_indent = len(match.group(1)) value = match.group(2).strip() if value not in {"|", "|-", "|+", ">", ">-", ">+"}: return [(run_index, match.group(2))] script: list[tuple[int, str]] = [] for index in range(run_index + 1, len(lines)): line = lines[index] if line.strip() and _indent(line) <= run_indent: break script.append((index, line)) return script def _step_env_bindings(lines: list[str], run_index: int) -> dict[str, str]: """Return literal step env names and values for a run declaration.""" run_match = RUN_RE.match(lines[run_index]) if not run_match: return {} field_indent = len(run_match.group(1)) step_start = run_index for index in range(run_index - 1, -1, -1): line = lines[index] if ( line.strip() and _indent(line) == field_indent - 2 and line.lstrip().startswith("- ") ): step_start = index break step_end = len(lines) for index in range(run_index + 1, len(lines)): line = lines[index] if ( line.strip() and _indent(line) == field_indent - 2 and line.lstrip().startswith("- ") ): step_end = index break bindings: dict[str, str] = {} # Job-level env is inherited by every run step. Locate the enclosing job # from normal Actions indentation before applying step-level overrides. job_key_indent = field_indent - 6 job_start: int | None = None for index in range(step_start - 1, -1, -1): code = lines[index].split("#", 1)[0] if _indent(code) == job_key_indent and JOB_RE.match(code): job_start = index break if job_start is not None: job_end = len(lines) for index in range(job_start + 1, len(lines)): code = lines[index].split("#", 1)[0] if code.strip() and _indent(code) == job_key_indent and JOB_RE.match(code): job_end = index break job_field_indent = field_indent - 4 for index in range(job_start + 1, job_end): code = lines[index].split("#", 1)[0] match = ENV_RE.match(code) if not match or len(match.group(1)) != job_field_indent: continue for env_line in lines[index + 1 : job_end]: env_code = env_line.split("#", 1)[0] if not env_code.strip(): continue if _indent(env_code) <= job_field_indent: break entry = ENV_ENTRY_RE.match(env_code) if entry and _indent(env_code) == job_field_indent + 2: bindings[entry.group(1)] = entry.group(2).strip("'\"") break for index in range(step_start, step_end): code = lines[index].split("#", 1)[0] match = ENV_RE.match(code) mapping_indent = len(match.group(1)) if match else -1 inline_step_field = bool(match and code.lstrip().startswith("- ")) if not match or not ( mapping_indent == field_indent or (inline_step_field and mapping_indent + 2 == field_indent) ): continue for env_line in lines[index + 1 : step_end]: code = env_line.split("#", 1)[0] if not code.strip(): continue if _indent(code) <= field_indent: break entry = ENV_ENTRY_RE.match(code) if entry and _indent(code) == field_indent + 2: bindings[entry.group(1)] = entry.group(2).strip("'\"") break return bindings def _shell_variable_reference(line: str, name: str) -> bool: escaped = re.escape(name) return bool( re.search( rf"(?:\$\{{{escaped}(?=[^A-Za-z0-9_])|\${escaped}\b|" rf"\$env:{escaped}\b|\$\{{env:{escaped}\}})", line, re.IGNORECASE, ) ) def _bash_assignment_persists(line: str, assignment: re.Match[str]) -> bool: """Return whether a Bash assignment changes the current shell.""" declaration = line[: assignment.start(1)].strip() if declaration: return True quote = "" escaped = False parentheses = 0 braces = 0 value = line[assignment.end() :] for index, character in enumerate(value): if escaped: escaped = False continue if character == "\\" and quote != "'": escaped = True continue if quote: if character == quote: quote = "" continue if character in "'\"`": quote = character continue if character == "(": parentheses += 1 continue if character == ")" and parentheses: parentheses -= 1 continue if character == "{" and (braces or (index and value[index - 1] == "$")): braces += 1 continue if character == "}" and braces: braces -= 1 continue if not parentheses and not braces: if character == ";": return True if character in "|&": return False if character.isspace(): remainder = value[index:].strip() return not remainder or remainder.startswith("#") # A bare assignment persists. On an incomplete quoted or nested value, # retain taint rather than treating malformed shell as validation. return not quote and not parentheses and not braces and not escaped def _is_untrusted_expression(value: str) -> bool: return any( SHELL_DATA_CONTEXT_RE.search(expression) or UNTRUSTED_GITHUB_CONTEXT_RE.search(expression) for expression in EXPRESSION_RE.findall(value) ) def _audit_command_file_data( path: Path, lines: list[str], run_index: int, ) -> list[Finding]: """Keep raw workflow/event values out of GitHub runner command files.""" findings: list[Finding] = [] bindings = { name: value for name, value in _step_env_bindings(lines, run_index).items() if _is_untrusted_expression(value) } unsafe_names = set(bindings) bash_control_depth = 0 for script_index, script_line in _run_script_lines(lines, run_index): if BASH_CONTROL_CLOSE_RE.match(script_line): bash_control_depth = max(0, bash_control_depth - 1) opens_bash_control = bool(BASH_CONTROL_OPEN_RE.match(script_line)) assignment = SHELL_ASSIGNMENT_RE.match(script_line) powershell_assignment = False if assignment is None: assignment = POWERSHELL_ASSIGNMENT_RE.match(script_line) powershell_assignment = assignment is not None if assignment: assigned_name = assignment.group(1) assignment_value = script_line[assignment.end() :] if ( "GITHUB_EVENT_PATH" in assignment_value or any( _shell_variable_reference(assignment_value, name) for name in unsafe_names ) ): unsafe_names.add(assigned_name) else: # A later literal or trusted assignment replaces the prior # value. Keeping stale taint would hide real findings in noise # and encourage suppressions around the policy. if powershell_assignment: unsafe_names = { name for name in unsafe_names if name.casefold() != assigned_name.casefold() } elif ( bash_control_depth == 0 and _bash_assignment_persists(script_line, assignment) ): unsafe_names.discard(assigned_name) if opens_bash_control: bash_control_depth += 1 if not GITHUB_COMMAND_FILE_RE.search(script_line): continue referenced_unsafe_names = sorted( name for name in unsafe_names if _shell_variable_reference(script_line, name) ) if referenced_unsafe_names: findings.append( Finding( path, script_index + 1, "untrusted workflow data must be validated or encoded " "before writing to GitHub command files " f"({', '.join(referenced_unsafe_names)})", ) ) return findings def _has_trigger(lines: list[str], event: str) -> bool: """Return whether the top-level Actions trigger includes *event*.""" event_re = re.compile(rf"(? list[str] | None: """Parse the small literal YAML string-list subset used by trust policy.""" values: list[str] = [] if inline_value: match = re.fullmatch(r"\[\s*(.*?)\s*\]", inline_value) if not match: return None raw_values = [] if not match.group(1) else match.group(1).split(",") else: key_indent = _indent(lines[key_index]) raw_values = [] for line in lines[key_index + 1 :]: code = line.split("#", 1)[0] if not code.strip(): continue if _indent(code) <= key_indent: break match = re.match(r"^\s*-\s*(.*?)\s*$", code) if not match: return None raw_values.append(match.group(1)) for raw_value in raw_values: value = raw_value.strip() if ( len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'} ): value = value[1:-1] if not re.fullmatch(r"[A-Za-z0-9._/-]+", value): return None values.append(value) return values def _audit_workflow_run_trigger(path: Path, lines: list[str]) -> list[Finding]: """Bind privileged workflow_run handlers to canonical upstream code.""" if not _has_trigger(lines, "workflow_run"): return [] event_index: int | None = None event_indent = 0 for index, line in enumerate(lines): code = line.split("#", 1)[0] if re.match(rf"^\s+{_yaml_key('workflow_run')}\s*:", code): event_index = index event_indent = _indent(code) break branch_declarations: list[tuple[int, list[str] | None]] = [] if event_index is not None: for index in range(event_index + 1, len(lines)): code = lines[index].split("#", 1)[0] if not code.strip(): continue if _indent(code) <= event_indent: break match = re.match( rf"^(\s*){_yaml_key('branches')}\s*:\s*(.*?)\s*$", code ) if match and len(match.group(1)) == event_indent + 2: branch_declarations.append( (index, _static_yaml_list(lines, index, match.group(2))) ) if len(branch_declarations) != 1 or branch_declarations[0][1] != ["main"]: line_number = ( branch_declarations[0][0] + 1 if branch_declarations else (event_index + 1 if event_index is not None else 1) ) return [ Finding( path, line_number, "workflow_run must restrict the triggering workflow to the " "literal canonical branch list branches: [main]", ) ] return [] def _audit_runner_job_timeouts(path: Path, lines: list[str]) -> list[Finding]: """Require each locally executed job to declare one bounded time budget.""" findings: list[Finding] = [] jobs_index = next( ( index for index, line in enumerate(lines) if JOBS_RE.match(line.split("#", 1)[0]) ), None, ) if jobs_index is None: return findings jobs_indent = _indent(lines[jobs_index]) job_starts: list[tuple[int, int]] = [] for index in range(jobs_index + 1, len(lines)): code = lines[index].split("#", 1)[0] if code.strip() and _indent(code) <= jobs_indent: break match = JOB_RE.match(code) if match and len(match.group(1)) == jobs_indent + 2: job_starts.append((index, len(match.group(1)))) for position, (job_index, job_indent) in enumerate(job_starts): end_index = ( job_starts[position + 1][0] if position + 1 < len(job_starts) else len(lines) ) direct_indent = job_indent + 2 runner_lines: list[int] = [] timeout_declarations: list[tuple[int, str]] = [] for index in range(job_index + 1, end_index): code = lines[index].split("#", 1)[0] run_match = RUNS_ON_RE.match(code) if run_match and len(run_match.group(1)) == direct_indent: runner_lines.append(index) timeout_match = TIMEOUT_RE.match(code) if timeout_match and len(timeout_match.group(1)) == direct_indent: timeout_declarations.append( (index, timeout_match.group(2).strip()) ) # Reusable-workflow caller jobs have `uses` instead of `runs-on` and # cannot declare timeout-minutes. The called workflow owns its budgets. if not runner_lines: continue if len(timeout_declarations) != 1: findings.append( Finding( path, runner_lines[0] + 1, "runner job must declare explicit timeout-minutes exactly once", ) ) continue timeout_index, timeout_value = timeout_declarations[0] if not timeout_value.isdigit() or not 1 <= int(timeout_value) <= 360: findings.append( Finding( path, timeout_index + 1, "runner job timeout-minutes must be a literal integer from 1 through 360", ) ) return findings def audit_workflow(path: Path) -> list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings = _audit_runner_job_timeouts(path, lines) findings.extend(_audit_workflow_run_trigger(path, lines)) has_workflow_run_trigger = _has_trigger(lines, "workflow_run") if _has_trigger(lines, "pull_request_target"): findings.append( Finding( path, 1, "pull_request_target is prohibited; use pull_request or isolate " "privileged work from pull-request code", ) ) if _has_trigger(lines, "pull_request"): for index, line in enumerate(lines): code = line.split("#", 1)[0] expressions = EXPRESSION_RE.findall(code) secret_names: set[str] = set() has_unresolved_secret_reference = False for expression in expressions: static_references = list(SECRET_CONTEXT_RE.finditer(expression)) secret_names.update( match.group(1) or match.group(3) for match in static_references ) if len(SECRET_CONTEXT_TOKEN_RE.findall(expression)) != len( static_references ): # Whole-context and dynamic references can expose any # repository secret, so they cannot use the public-key # exception reserved for a statically named value. has_unresolved_secret_reference = True if ( secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS or has_unresolved_secret_reference ): findings.append( Finding( path, index + 1, "pull_request workflows must not reference confidential " "repository secrets; isolate privileged work in a non-PR workflow", ) ) permission_declarations = [ (index, match) for index, line in enumerate(lines) if (match := PERMISSIONS_RE.match(line.split("#", 1)[0])) ] top_level_permissions = [ (index, match) for index, match in permission_declarations if not match.group(1) ] if len(top_level_permissions) != 1: findings.append( Finding( path, 1, "workflow must declare top-level permissions explicitly exactly once", ) ) for index, match in permission_declarations: # GitHub applies job-level permissions after the workflow default. Audit # every declaration so a job cannot reintroduce read-all, write-all, or # a dynamic grant beneath an otherwise least-privilege workflow. inline_value = match.group(2) if inline_value not in {"", "{}"}: scope = "workflow" if not match.group(1) else "job" findings.append( Finding( path, index + 1, f"{scope} permissions must use a scope mapping or explicit empty mapping", ) ) for index, line in enumerate(lines): line_number = index + 1 code = line.split("#", 1)[0] if HOSTED_LATEST_RE.search(code): findings.append( Finding( path, line_number, "mutable hosted runner label; use an explicit dated image", ) ) if RUN_RE.match(code): findings.extend(_audit_command_file_data(path, lines, index)) for script_index, script_line in _run_script_lines(lines, index): if has_workflow_run_trigger and WORKFLOW_RUN_INGRESS_COMMAND_RE.search( script_line ): findings.append( Finding( path, script_index + 1, "workflow_run scripts must not acquire upstream " "workflow artifacts or repository code", ) ) for expression in EXPRESSION_RE.findall(script_line): if SHELL_DATA_CONTEXT_RE.search(expression): findings.append( Finding( path, script_index + 1, "workflow inputs, dispatch payloads, secrets, " "GitHub contexts, and step/job outputs must enter " "run scripts through env", ) ) elif UNTRUSTED_GITHUB_CONTEXT_RE.search(expression): findings.append( Finding( path, script_index + 1, "untrusted GitHub metadata must enter run scripts through env", ) ) match = USES_RE.search(code) if not match: continue dependency = match.group(1).strip("'\"") if dependency.startswith("./"): continue if has_workflow_run_trigger and dependency.lower().startswith( WORKFLOW_RUN_ARTIFACT_ACTION_PREFIX ): findings.append( Finding( path, line_number, "workflow_run must not download upstream workflow artifacts", ) ) if dependency.startswith("docker://"): if not CONTAINER_DIGEST_RE.fullmatch(dependency): findings.append( Finding( path, line_number, "container action is not pinned to a sha256 digest", ) ) continue owner_and_action, separator, ref = dependency.rpartition("@") if not separator or "/" not in owner_and_action or not ACTION_SHA_RE.fullmatch(ref): findings.append( Finding( path, line_number, "remote action or reusable workflow is not pinned to a full commit SHA", ) ) continue # GitHub repository names are case-insensitive, so normalize before # applying checkout-specific credential controls. if not dependency.lower().startswith(CHECKOUT_PREFIX): continue if ref not in PROTECTED_CHECKOUT_PINS: findings.append( Finding( path, line_number, "checkout pin is outside the reviewed privileged-event " "protection baseline", ) ) checkout_block = _checkout_block(lines, index) if has_workflow_run_trigger and any( WORKFLOW_RUN_CODE_REF_RE.search(block_line.split("#", 1)[0]) for _, block_line in checkout_block ): findings.append( Finding( path, line_number, "workflow_run checkout must not select code from triggering-run metadata", ) ) unsafe_pr_settings = [ (block_index, block_line) for block_index, block_line in checkout_block if re.match( rf"^\s*{_yaml_key('allow-unsafe-pr-checkout')}\s*:", block_line, ) ] for setting_index, setting in unsafe_pr_settings: if not re.match( rf"^\s*{_yaml_key('allow-unsafe-pr-checkout')}\s*:\s*" r"false(?:\s|$)", setting, ): findings.append( Finding( path, setting_index + 1, "checkout must not opt out of privileged-event PR protection", ) ) credential_settings = [ (block_index, block_line) for block_index, block_line in checkout_block if re.match( rf"^\s*{_yaml_key('persist-credentials')}\s*:", block_line ) ] if len(credential_settings) != 1: findings.append( Finding( path, line_number, "checkout must set persist-credentials explicitly exactly once", ) ) continue setting_index, setting = credential_settings[0] value_match = re.match( rf"^\s*{_yaml_key('persist-credentials')}\s*:\s*(true|false)\b", setting, ) if not value_match: findings.append( Finding( path, setting_index + 1, "persist-credentials must be the literal true or false", ) ) elif value_match.group(1) == "true" and WRITE_CREDENTIAL_RATIONALE not in setting: findings.append( Finding( path, setting_index + 1, f"persisted checkout credentials require {WRITE_CREDENTIAL_RATIONALE}", ) ) return findings def audit_directory(workflow_directory: Path) -> list[Finding]: findings: list[Finding] = [] paths = sorted(workflow_directory.glob("*.yml")) + sorted( workflow_directory.glob("*.yaml") ) for path in paths: findings.extend(audit_workflow(path)) return findings def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "workflow_directory", nargs="?", type=Path, default=Path(__file__).resolve().parents[1] / ".github" / "workflows", ) return parser.parse_args() def main() -> int: args = parse_args() if not args.workflow_directory.is_dir(): print(f"workflow directory not found: {args.workflow_directory}", file=sys.stderr) return 2 findings = audit_directory(args.workflow_directory) if findings: for finding in findings: print(finding.render(), file=sys.stderr) print(f"GitHub Actions trust validation failed ({len(findings)} finding(s)).", file=sys.stderr) return 1 print("GitHub Actions trust validation passed.") return 0 if __name__ == "__main__": raise SystemExit(main())