|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Fail if a workflow job that calls Claude, or .github/egress-firewall.yaml, breaks a rule in CLAUDE.md, |
| 3 | +"Security hardening for GitHub Actions". Run from the repository root. A job calls Claude when it runs the |
| 4 | +Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID. |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +import pathlib |
| 9 | +import shlex |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | + |
| 13 | +FIREWALL_RUNNER = "ubuntu-24.04-firewall" |
| 14 | +WORKFLOW_DIR = pathlib.Path(".github/workflows") |
| 15 | +POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml") |
| 16 | +SIGN_IN_MARKER = "anthropic_federation_rule_id" |
| 17 | +CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action") |
| 18 | +HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".' |
| 19 | + |
| 20 | +# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule. |
| 21 | +EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {} |
| 22 | +EXEMPT_FROM_AUTO_MODE: dict[str, str] = { |
| 23 | + "claude.yml:claude": "answers @claude mentions, and for those the Claude Code action sets --permission-mode acceptEdits itself", |
| 24 | +} |
| 25 | + |
| 26 | + |
| 27 | +def stop(message: str): |
| 28 | + sys.exit(f"::error::{message}") |
| 29 | + |
| 30 | + |
| 31 | +def load_yaml(path: pathlib.Path): |
| 32 | + """Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent.""" |
| 33 | + try: |
| 34 | + import yaml |
| 35 | + except ImportError: |
| 36 | + try: |
| 37 | + result = subprocess.run( |
| 38 | + ["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True |
| 39 | + ) |
| 40 | + except FileNotFoundError: |
| 41 | + stop( |
| 42 | + f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. " |
| 43 | + "Add a step that runs 'pip install pyyaml' before this check." |
| 44 | + ) |
| 45 | + except subprocess.CalledProcessError: |
| 46 | + stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.") |
| 47 | + return json.loads(result.stdout) |
| 48 | + try: |
| 49 | + with path.open(encoding="utf-8") as handle: |
| 50 | + return yaml.safe_load(handle) |
| 51 | + except yaml.YAMLError as error: |
| 52 | + stop(f"Cannot read {path}: it is not valid YAML ({error}).") |
| 53 | + |
| 54 | + |
| 55 | +def contains_marker(node) -> bool: |
| 56 | + """Whether any key or string under node contains SIGN_IN_MARKER, ignoring case.""" |
| 57 | + if isinstance(node, dict): |
| 58 | + return any(contains_marker(k) or contains_marker(v) for k, v in node.items()) |
| 59 | + if isinstance(node, list): |
| 60 | + return any(contains_marker(item) for item in node) |
| 61 | + return isinstance(node, str) and SIGN_IN_MARKER in node.lower() |
| 62 | + |
| 63 | + |
| 64 | +def load_local_action(uses: str): |
| 65 | + """The parsed action file of a local action (uses: ./path), or None.""" |
| 66 | + if not uses.startswith("./"): |
| 67 | + return None |
| 68 | + for name in ("action.yml", "action.yaml"): |
| 69 | + action_file = pathlib.Path(uses) / name |
| 70 | + if action_file.is_file(): |
| 71 | + action = load_yaml(action_file) |
| 72 | + return action if isinstance(action, dict) else {} |
| 73 | + return None |
| 74 | + |
| 75 | + |
| 76 | +def steps_of(job: dict) -> list[dict]: |
| 77 | + return [step for step in job.get("steps") or [] if isinstance(step, dict)] |
| 78 | + |
| 79 | + |
| 80 | +def runs_claude_code_action(step: dict) -> bool: |
| 81 | + """Whether the step runs the Claude Code action: the published action, or a |
| 82 | + local action that accepts a claude_args input.""" |
| 83 | + uses = str(step.get("uses", "")) |
| 84 | + if uses.lower().startswith(CLAUDE_ACTIONS): |
| 85 | + return True |
| 86 | + action = load_local_action(uses) |
| 87 | + return action is not None and "claude_args" in (action.get("inputs") or {}) |
| 88 | + |
| 89 | + |
| 90 | +def job_calls_claude(job: dict) -> bool: |
| 91 | + if contains_marker(job): |
| 92 | + return True |
| 93 | + for step in steps_of(job): |
| 94 | + if runs_claude_code_action(step): |
| 95 | + return True |
| 96 | + action = load_local_action(str(step.get("uses", ""))) |
| 97 | + if action is not None and contains_marker(action): |
| 98 | + return True |
| 99 | + return False |
| 100 | + |
| 101 | + |
| 102 | +def permission_mode_problem(step: dict, exempt: bool) -> str | None: |
| 103 | + """The message for a step whose permission mode is wrong, or None if it is right. |
| 104 | +
|
| 105 | + A step must set auto mode. A step of a job in EXEMPT_FROM_AUTO_MODE must set no mode at all. |
| 106 | + """ |
| 107 | + inputs = step.get("with") or {} |
| 108 | + lines = str(inputs.get("claude_args", "")).splitlines() |
| 109 | + text = " ".join(line for line in lines if not line.strip().startswith("#")) |
| 110 | + try: |
| 111 | + args = shlex.split(text, comments=True) |
| 112 | + except ValueError: |
| 113 | + return "'claude_args' has a quote that is never closed. Close it" |
| 114 | + modes = [] |
| 115 | + for index, arg in enumerate(args): |
| 116 | + if arg == "--dangerously-skip-permissions": |
| 117 | + return ( |
| 118 | + "remove '--dangerously-skip-permissions' from 'claude_args': " |
| 119 | + "it turns off permission checks" |
| 120 | + ) |
| 121 | + if arg == "--permission-mode": |
| 122 | + modes.append(args[index + 1] if index + 1 < len(args) else "") |
| 123 | + elif arg.startswith("--permission-mode="): |
| 124 | + modes.append(arg.split("=", 1)[1]) |
| 125 | + if exempt and modes: |
| 126 | + return ( |
| 127 | + "remove '--permission-mode' from 'claude_args': this job is listed in " |
| 128 | + "EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed " |
| 129 | + "there must not set a permission mode" |
| 130 | + ) |
| 131 | + if not modes and not exempt: |
| 132 | + return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'" |
| 133 | + for mode in modes: |
| 134 | + if mode == "": |
| 135 | + return ( |
| 136 | + "'claude_args' has '--permission-mode' with nothing after it. " |
| 137 | + "Write '--permission-mode auto'" |
| 138 | + ) |
| 139 | + if mode != "auto": |
| 140 | + return ( |
| 141 | + f"'claude_args' has '--permission-mode {mode}'. " |
| 142 | + "Change it to '--permission-mode auto'" |
| 143 | + ) |
| 144 | + if "defaultMode" in str(inputs.get("settings", "")): |
| 145 | + return ( |
| 146 | + "remove 'defaultMode' from the step's 'settings': " |
| 147 | + "'settings' must not set a permission mode" |
| 148 | + ) |
| 149 | + return None |
| 150 | + |
| 151 | + |
| 152 | +def check_job(file_name: str, job_id: str, job: dict) -> list[str]: |
| 153 | + key = f"{file_name}:{job_id}" |
| 154 | + where = f".github/workflows/{file_name}: job '{job_id}'" |
| 155 | + errors = [] |
| 156 | + runs_on = job.get("runs-on") |
| 157 | + if isinstance(runs_on, list) and len(runs_on) == 1: |
| 158 | + runs_on = runs_on[0] |
| 159 | + if key in EXEMPT_FROM_FIREWALL_RUNNER: |
| 160 | + print( |
| 161 | + f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. " |
| 162 | + f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}." |
| 163 | + ) |
| 164 | + elif runs_on != FIREWALL_RUNNER: |
| 165 | + if "runs-on" not in job: |
| 166 | + has = "no 'runs-on'" |
| 167 | + elif isinstance(job["runs-on"], str): |
| 168 | + has = f"'runs-on: {job['runs-on']}'" |
| 169 | + else: |
| 170 | + has = "a 'runs-on' list or group" |
| 171 | + errors.append( |
| 172 | + f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. " |
| 173 | + f"It has {has}. {HELP}" |
| 174 | + ) |
| 175 | + exempt = key in EXEMPT_FROM_AUTO_MODE |
| 176 | + if exempt: |
| 177 | + print( |
| 178 | + f"Auto permission mode is not required for job '{job_id}' in {file_name}. " |
| 179 | + f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}." |
| 180 | + ) |
| 181 | + for index, step in enumerate(steps_of(job), start=1): |
| 182 | + if not runs_claude_code_action(step): |
| 183 | + continue |
| 184 | + problem = permission_mode_problem(step, exempt) |
| 185 | + if problem: |
| 186 | + step_label = f"step '{step['name']}'" if "name" in step else f"step {index}" |
| 187 | + errors.append(f"{where}, {step_label}: {problem}. {HELP}") |
| 188 | + return errors |
| 189 | + |
| 190 | + |
| 191 | +def check_policy() -> list[str]: |
| 192 | + if not POLICY_PATH.is_file(): |
| 193 | + return [ |
| 194 | + f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it " |
| 195 | + f"to limit outbound network access. {HELP}" |
| 196 | + ] |
| 197 | + policy = load_yaml(POLICY_PATH) |
| 198 | + if not isinstance(policy, dict): |
| 199 | + return [ |
| 200 | + f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' " |
| 201 | + f"and an 'allow:' list of hosts. {HELP}" |
| 202 | + ] |
| 203 | + errors = [] |
| 204 | + if "mode" not in policy: |
| 205 | + errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}") |
| 206 | + elif policy["mode"] != "enforce": |
| 207 | + errors.append( |
| 208 | + f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}" |
| 209 | + ) |
| 210 | + allow = policy.get("allow") |
| 211 | + if not isinstance(allow, list) or not allow: |
| 212 | + errors.append( |
| 213 | + f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' " |
| 214 | + f"each host the jobs need. {HELP}" |
| 215 | + ) |
| 216 | + else: |
| 217 | + for host in allow: |
| 218 | + if "*" in str(host): |
| 219 | + errors.append( |
| 220 | + f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. " |
| 221 | + f"Name each host in full. {HELP}" |
| 222 | + ) |
| 223 | + return errors |
| 224 | + |
| 225 | + |
| 226 | +def main() -> int: |
| 227 | + if not WORKFLOW_DIR.is_dir(): |
| 228 | + stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.") |
| 229 | + errors = [] |
| 230 | + checked = 0 |
| 231 | + for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]): |
| 232 | + workflow = load_yaml(path) |
| 233 | + jobs = workflow.get("jobs") if isinstance(workflow, dict) else None |
| 234 | + for job_id, job in (jobs or {}).items(): |
| 235 | + if not isinstance(job, dict) or not job_calls_claude(job): |
| 236 | + continue |
| 237 | + checked += 1 |
| 238 | + errors.extend(check_job(path.name, job_id, job)) |
| 239 | + if checked: |
| 240 | + errors.extend(check_policy()) |
| 241 | + for error in errors: |
| 242 | + print(f"::error::{error}") |
| 243 | + if errors: |
| 244 | + return 1 |
| 245 | + if checked == 0: |
| 246 | + print("OK: no workflow job calls Claude, so there was nothing to check.") |
| 247 | + else: |
| 248 | + print(f"OK: checked {checked} job(s) that call Claude and found no problems.") |
| 249 | + return 0 |
| 250 | + |
| 251 | + |
| 252 | +if __name__ == "__main__": |
| 253 | + sys.exit(main()) |
0 commit comments