|
| 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 re |
| 10 | +import shlex |
| 11 | +import subprocess |
| 12 | +import sys |
| 13 | + |
| 14 | +FIREWALL_RUNNER = "ubuntu-24.04-firewall" |
| 15 | +WORKFLOW_DIR = pathlib.Path(".github/workflows") |
| 16 | +POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml") |
| 17 | +SIGN_IN_MARKER = "anthropic_federation_rule_id" |
| 18 | +CLAUDE_ACTIONS = ("anthropics/claude-code-action", "anthropics/claude-code-base-action") |
| 19 | +HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".' |
| 20 | +# Claude Code runs auto mode only on claude-opus-4-6 and newer models. On an older model it |
| 21 | +# falls back to its default permission mode, with no safety review. |
| 22 | +AUTO_MODE_MIN_VERSION = (4, 6) |
| 23 | +# The version in a model name: claude-opus-4-6, claude-sonnet-4-5-20250929, claude-3-5-sonnet-latest. |
| 24 | +MODEL_VERSION = re.compile( |
| 25 | + r"claude-(?:(?:opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?" |
| 26 | + r"|(\d+)(?:-(\d{1,2}))?-(?:opus|sonnet|haiku))(?![\d.])" |
| 27 | +) |
| 28 | + |
| 29 | +# Key: "<workflow file name>:<job id>". Value: why that job is exempt from the table's rule. |
| 30 | +EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {} |
| 31 | +EXEMPT_FROM_AUTO_MODE: dict[str, str] = {} |
| 32 | + |
| 33 | + |
| 34 | +def stop(message: str): |
| 35 | + sys.exit(f"::error::{message}") |
| 36 | + |
| 37 | + |
| 38 | +def load_yaml(path: pathlib.Path): |
| 39 | + """Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent.""" |
| 40 | + try: |
| 41 | + import yaml |
| 42 | + except ImportError: |
| 43 | + try: |
| 44 | + result = subprocess.run( |
| 45 | + ["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True |
| 46 | + ) |
| 47 | + except FileNotFoundError: |
| 48 | + stop( |
| 49 | + f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. " |
| 50 | + "Add a step that runs 'pip install pyyaml' before this check." |
| 51 | + ) |
| 52 | + except subprocess.CalledProcessError: |
| 53 | + stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.") |
| 54 | + return json.loads(result.stdout) |
| 55 | + try: |
| 56 | + with path.open(encoding="utf-8") as handle: |
| 57 | + return yaml.safe_load(handle) |
| 58 | + except yaml.YAMLError as error: |
| 59 | + stop(f"Cannot read {path}: it is not valid YAML ({error}).") |
| 60 | + |
| 61 | + |
| 62 | +def contains_marker(node) -> bool: |
| 63 | + """Whether any key or string under node contains SIGN_IN_MARKER, ignoring case.""" |
| 64 | + if isinstance(node, dict): |
| 65 | + return any(contains_marker(k) or contains_marker(v) for k, v in node.items()) |
| 66 | + if isinstance(node, list): |
| 67 | + return any(contains_marker(item) for item in node) |
| 68 | + return isinstance(node, str) and SIGN_IN_MARKER in node.lower() |
| 69 | + |
| 70 | + |
| 71 | +def load_local_action(uses: str): |
| 72 | + """The parsed action file of a local action (uses: ./path), or None.""" |
| 73 | + if not uses.startswith("./"): |
| 74 | + return None |
| 75 | + for name in ("action.yml", "action.yaml"): |
| 76 | + action_file = pathlib.Path(uses) / name |
| 77 | + if action_file.is_file(): |
| 78 | + action = load_yaml(action_file) |
| 79 | + return action if isinstance(action, dict) else {} |
| 80 | + return None |
| 81 | + |
| 82 | + |
| 83 | +def steps_of(job: dict) -> list[dict]: |
| 84 | + return [step for step in job.get("steps") or [] if isinstance(step, dict)] |
| 85 | + |
| 86 | + |
| 87 | +def runs_claude_code_action(step: dict) -> bool: |
| 88 | + """Whether the step runs the Claude Code action: the published action, or a |
| 89 | + local action that accepts a claude_args input.""" |
| 90 | + uses = str(step.get("uses", "")) |
| 91 | + if uses.lower().startswith(CLAUDE_ACTIONS): |
| 92 | + return True |
| 93 | + action = load_local_action(uses) |
| 94 | + return action is not None and "claude_args" in (action.get("inputs") or {}) |
| 95 | + |
| 96 | + |
| 97 | +def job_calls_claude(job: dict) -> bool: |
| 98 | + if contains_marker(job): |
| 99 | + return True |
| 100 | + for step in steps_of(job): |
| 101 | + if runs_claude_code_action(step): |
| 102 | + return True |
| 103 | + action = load_local_action(str(step.get("uses", ""))) |
| 104 | + if action is not None and contains_marker(action): |
| 105 | + return True |
| 106 | + return False |
| 107 | + |
| 108 | + |
| 109 | +def permission_mode_problem(step: dict, exempt: bool, inherited_env: dict) -> str | None: |
| 110 | + """The message for a step whose permission mode is wrong, or None if it is right. |
| 111 | +
|
| 112 | + A step must set auto mode, on a model that supports it. A step of a job in |
| 113 | + EXEMPT_FROM_AUTO_MODE must set no mode at all. inherited_env is the workflow's and the |
| 114 | + job's 'env'. |
| 115 | + """ |
| 116 | + inputs = step.get("with") or {} |
| 117 | + lines = str(inputs.get("claude_args", "")).splitlines() |
| 118 | + text = " ".join(line for line in lines if not line.strip().startswith("#")) |
| 119 | + try: |
| 120 | + args = shlex.split(text, comments=True) |
| 121 | + except ValueError: |
| 122 | + return "'claude_args' has a quote that is never closed. Close it" |
| 123 | + modes = [] |
| 124 | + for index, arg in enumerate(args): |
| 125 | + if arg == "--dangerously-skip-permissions": |
| 126 | + return ( |
| 127 | + "remove '--dangerously-skip-permissions' from 'claude_args': " |
| 128 | + "it turns off permission checks" |
| 129 | + ) |
| 130 | + if arg == "--permission-mode": |
| 131 | + modes.append(args[index + 1] if index + 1 < len(args) else "") |
| 132 | + elif arg.startswith("--permission-mode="): |
| 133 | + modes.append(arg.split("=", 1)[1]) |
| 134 | + if exempt and modes: |
| 135 | + return ( |
| 136 | + "remove '--permission-mode' from 'claude_args': this job is listed in " |
| 137 | + "EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed " |
| 138 | + "there must not set a permission mode" |
| 139 | + ) |
| 140 | + if not modes and not exempt: |
| 141 | + return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'" |
| 142 | + for mode in modes: |
| 143 | + if mode == "": |
| 144 | + return ( |
| 145 | + "'claude_args' has '--permission-mode' with nothing after it. " |
| 146 | + "Write '--permission-mode auto'" |
| 147 | + ) |
| 148 | + if mode != "auto": |
| 149 | + return ( |
| 150 | + f"'claude_args' has '--permission-mode {mode}'. " |
| 151 | + "Change it to '--permission-mode auto'" |
| 152 | + ) |
| 153 | + env = {**inherited_env, **(step.get("env") or {})} |
| 154 | + models = [ |
| 155 | + ("the step's 'model'", inputs.get("model", "")), |
| 156 | + ("ANTHROPIC_MODEL", env.get("ANTHROPIC_MODEL", "")), |
| 157 | + ] |
| 158 | + models += [ |
| 159 | + (f"'{flag}' in 'claude_args'", value) |
| 160 | + for flag in ("--model", "--fallback-model") |
| 161 | + for value in flag_values(args, flag) |
| 162 | + ] |
| 163 | + settings = [("the step's 'settings'", inputs.get("settings", ""))] |
| 164 | + settings += [ |
| 165 | + ("'--settings' in 'claude_args'", value) for value in flag_values(args, "--settings") |
| 166 | + ] |
| 167 | + for where, value in settings: |
| 168 | + text = settings_text(value) |
| 169 | + if text is None: |
| 170 | + return ( |
| 171 | + f"{where} names a file outside the repository, which this check cannot read. " |
| 172 | + "Use inline settings or a file inside the repository" |
| 173 | + ) |
| 174 | + if "defaultMode" in text: |
| 175 | + return f"remove 'defaultMode' from {where}: settings must not set a permission mode" |
| 176 | + try: |
| 177 | + parsed = json.loads(text) if text else {} |
| 178 | + except ValueError: |
| 179 | + parsed = {} |
| 180 | + if isinstance(parsed, dict) and "model" in parsed: |
| 181 | + models.append((f"'model' in {where}", parsed["model"])) |
| 182 | + if not exempt: |
| 183 | + for where, model in models: |
| 184 | + if predates_auto_mode(str(model or "")): |
| 185 | + return ( |
| 186 | + f"{where} is '{model}', which Claude Code does not run in auto mode: it " |
| 187 | + "falls back to the default permission mode. Use claude-opus-4-6 or a newer model" |
| 188 | + ) |
| 189 | + return None |
| 190 | + |
| 191 | + |
| 192 | +def flag_values(args: list[str], flag: str) -> list[str]: |
| 193 | + """The values a flag in claude_args is given, as '--flag value' or '--flag=value'.""" |
| 194 | + values = [ |
| 195 | + args[index + 1] for index, arg in enumerate(args) if arg == flag and index + 1 < len(args) |
| 196 | + ] |
| 197 | + return values + [arg.split("=", 1)[1] for arg in args if arg.startswith(f"{flag}=")] |
| 198 | + |
| 199 | + |
| 200 | +def predates_auto_mode(model: str) -> bool: |
| 201 | + """Whether the model is older than AUTO_MODE_MIN_VERSION. A name with no version, such as |
| 202 | + 'opus' or 'default', stands for a current model, except 'haiku' (claude-haiku-4-5).""" |
| 203 | + if model.strip().lower() == "haiku": |
| 204 | + return True |
| 205 | + match = MODEL_VERSION.search(model.lower()) |
| 206 | + if not match: |
| 207 | + return False |
| 208 | + major, minor = match.group(1, 2) if match.group(1) else match.group(3, 4) |
| 209 | + return (int(major), int(minor or 0)) < AUTO_MODE_MIN_VERSION |
| 210 | + |
| 211 | + |
| 212 | +def settings_text(value) -> str | None: |
| 213 | + """The settings JSON a 'settings' value stands for: the value itself, or the contents of |
| 214 | + the file it names when it is a path inside the repository. None when it names a path |
| 215 | + outside the repository.""" |
| 216 | + text = str(value or "").strip() |
| 217 | + if not text or text.startswith("{"): |
| 218 | + return text |
| 219 | + path = pathlib.Path(text) |
| 220 | + root = pathlib.Path.cwd().resolve() |
| 221 | + try: |
| 222 | + resolved = path.resolve() |
| 223 | + resolved.relative_to(root) |
| 224 | + except (OSError, ValueError): |
| 225 | + return None |
| 226 | + if resolved.is_file(): |
| 227 | + return resolved.read_text(encoding="utf-8", errors="replace") |
| 228 | + return text |
| 229 | + |
| 230 | + |
| 231 | +def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> list[str]: |
| 232 | + key = f"{file_name}:{job_id}" |
| 233 | + where = f".github/workflows/{file_name}: job '{job_id}'" |
| 234 | + errors = [] |
| 235 | + runs_on = job.get("runs-on") |
| 236 | + if isinstance(runs_on, list) and len(runs_on) == 1: |
| 237 | + runs_on = runs_on[0] |
| 238 | + if key in EXEMPT_FROM_FIREWALL_RUNNER: |
| 239 | + print( |
| 240 | + f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. " |
| 241 | + f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}." |
| 242 | + ) |
| 243 | + elif runs_on != FIREWALL_RUNNER: |
| 244 | + if "runs-on" not in job: |
| 245 | + has = "no 'runs-on'" |
| 246 | + elif isinstance(job["runs-on"], str): |
| 247 | + has = f"'runs-on: {job['runs-on']}'" |
| 248 | + else: |
| 249 | + has = "a 'runs-on' list or group" |
| 250 | + errors.append( |
| 251 | + f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. " |
| 252 | + f"It has {has}. {HELP}" |
| 253 | + ) |
| 254 | + exempt = key in EXEMPT_FROM_AUTO_MODE |
| 255 | + if exempt: |
| 256 | + print( |
| 257 | + f"Auto permission mode is not required for job '{job_id}' in {file_name}. " |
| 258 | + f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}." |
| 259 | + ) |
| 260 | + if "defaultMode" in json.dumps(job): |
| 261 | + # Catches a settings file that an earlier step of the job writes, which the |
| 262 | + # step-level check cannot read. |
| 263 | + errors.append( |
| 264 | + f"{where} mentions 'defaultMode': settings must not set a permission mode. {HELP}" |
| 265 | + ) |
| 266 | + for index, step in enumerate(steps_of(job), start=1): |
| 267 | + if not runs_claude_code_action(step): |
| 268 | + continue |
| 269 | + inherited_env = {**workflow_env, **(job.get("env") or {})} |
| 270 | + problem = permission_mode_problem(step, exempt, inherited_env) |
| 271 | + if problem: |
| 272 | + step_label = f"step '{step['name']}'" if "name" in step else f"step {index}" |
| 273 | + errors.append(f"{where}, {step_label}: {problem}. {HELP}") |
| 274 | + return errors |
| 275 | + |
| 276 | + |
| 277 | +def check_policy() -> list[str]: |
| 278 | + if not POLICY_PATH.is_file(): |
| 279 | + return [ |
| 280 | + f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it " |
| 281 | + f"to limit outbound network access. {HELP}" |
| 282 | + ] |
| 283 | + policy = load_yaml(POLICY_PATH) |
| 284 | + if not isinstance(policy, dict): |
| 285 | + return [ |
| 286 | + f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' " |
| 287 | + f"and an 'allow:' list of hosts. {HELP}" |
| 288 | + ] |
| 289 | + errors = [] |
| 290 | + if "mode" not in policy: |
| 291 | + errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}") |
| 292 | + elif policy["mode"] != "enforce": |
| 293 | + errors.append( |
| 294 | + f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}" |
| 295 | + ) |
| 296 | + allow = policy.get("allow") |
| 297 | + if not isinstance(allow, list) or not allow: |
| 298 | + errors.append( |
| 299 | + f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' " |
| 300 | + f"each host the jobs need. {HELP}" |
| 301 | + ) |
| 302 | + else: |
| 303 | + for host in allow: |
| 304 | + if "*" in str(host): |
| 305 | + errors.append( |
| 306 | + f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. " |
| 307 | + f"Name each host in full. {HELP}" |
| 308 | + ) |
| 309 | + return errors |
| 310 | + |
| 311 | + |
| 312 | +def main() -> int: |
| 313 | + if not WORKFLOW_DIR.is_dir(): |
| 314 | + stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.") |
| 315 | + errors = [] |
| 316 | + checked = 0 |
| 317 | + for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]): |
| 318 | + workflow = load_yaml(path) |
| 319 | + jobs = workflow.get("jobs") if isinstance(workflow, dict) else None |
| 320 | + for job_id, job in (jobs or {}).items(): |
| 321 | + if not isinstance(job, dict) or not job_calls_claude(job): |
| 322 | + continue |
| 323 | + checked += 1 |
| 324 | + errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {})) |
| 325 | + if checked: |
| 326 | + errors.extend(check_policy()) |
| 327 | + for error in errors: |
| 328 | + print(f"::error::{error}") |
| 329 | + if errors: |
| 330 | + return 1 |
| 331 | + if checked == 0: |
| 332 | + print("OK: no workflow job calls Claude, so there was nothing to check.") |
| 333 | + else: |
| 334 | + print(f"OK: checked {checked} job(s) that call Claude and found no problems.") |
| 335 | + return 0 |
| 336 | + |
| 337 | + |
| 338 | +if __name__ == "__main__": |
| 339 | + sys.exit(main()) |
0 commit comments