|
| 1 | +# /// script |
| 2 | +# requires-python = ">=3.13" |
| 3 | +# dependencies = [ |
| 4 | +# "ruyaml", |
| 5 | +# ] |
| 6 | +# /// |
| 7 | +# |
| 8 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 9 | +# or more contributor license agreements. See the NOTICE file |
| 10 | +# distributed with this work for additional information |
| 11 | +# regarding copyright ownership. The ASF licenses this file |
| 12 | +# to you under the Apache License, Version 2.0 (the |
| 13 | +# "License"); you may not use this file except in compliance |
| 14 | +# with the License. You may obtain a copy of the License at |
| 15 | +# |
| 16 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 17 | +# |
| 18 | +# Unless required by applicable law or agreed to in writing, |
| 19 | +# software distributed under the License is distributed on an |
| 20 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 21 | +# KIND, either express or implied. See the License for the |
| 22 | +# specific language governing permissions and limitations |
| 23 | +# under the License. |
| 24 | +# |
| 25 | +# |
| 26 | + |
| 27 | +import fnmatch |
| 28 | +import os |
| 29 | +import re |
| 30 | +import sys |
| 31 | + |
| 32 | +from pathlib import Path |
| 33 | + |
| 34 | +from gateway import load_yaml, on_gha |
| 35 | + |
| 36 | +re_action = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?(@(.+))?$" |
| 37 | +re_local_file = r"^[.]/.+" |
| 38 | + |
| 39 | +re_docker_sha = r"^docker://[A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+@sha256:[0-9a-f]{64}$" |
| 40 | +re_action_hash = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?@[0-9a-f]{40}$" |
| 41 | + |
| 42 | +def _iter_uses_nodes(node: dict, yaml_path: str = ""): |
| 43 | + """ |
| 44 | + Walk the entire YAML structure (dicts/lists/scalars) and yield every value |
| 45 | + whose key is exactly 'uses', along with a best-effort YAML-path string. |
| 46 | + """ |
| 47 | + if isinstance(node, dict): |
| 48 | + for k, v in node.items(): |
| 49 | + next_path = f"{"" if len(yaml_path) == 0 else f"{yaml_path}."}{k}" |
| 50 | + if k == "uses": |
| 51 | + yield next_path, v |
| 52 | + yield from _iter_uses_nodes(v, next_path) |
| 53 | + elif isinstance(node, list): |
| 54 | + for i, item in enumerate(node): |
| 55 | + next_path = f"{yaml_path}[{i}]" |
| 56 | + yield from _iter_uses_nodes(item, next_path) |
| 57 | + else: |
| 58 | + return |
| 59 | + |
| 60 | + |
| 61 | +def check_project_actions(repository: str | os.PathLike, approved_patterns_file: str | os.PathLike) -> None: |
| 62 | + """ |
| 63 | + Check that all GitHub actions used in workflows and actions are approved. |
| 64 | +
|
| 65 | + See GitHub documentation https://docs.github.com/en/enterprise-cloud@latest/admin/enforcing-policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise |
| 66 | +
|
| 67 | + @param repository: Path to the repository root directory to check. YAML files under '.github/workflows' and '.github/actions' will be checked. |
| 68 | + @param approved_patterns_file: Path to the YAML file containing approved action patterns. |
| 69 | + """ |
| 70 | + repo_root = Path(repository) |
| 71 | + if not repo_root.exists(): |
| 72 | + raise FileNotFoundError(f"Repository path does not exist: {repo_root}") |
| 73 | + |
| 74 | + # Only consider workflows under '.github/workflows' (the only directory mentioned). |
| 75 | + github_dir = repo_root / ".github" |
| 76 | + if not github_dir.is_dir(): |
| 77 | + print(f"No directory found at: {github_dir}") |
| 78 | + return |
| 79 | + |
| 80 | + yaml_files: list[Path] = sorted( |
| 81 | + [ |
| 82 | + *github_dir.rglob("workflows/*.yml"), |
| 83 | + *github_dir.rglob("workflows/*.yaml"), |
| 84 | + *github_dir.rglob("actions/**/*.yml"), |
| 85 | + *github_dir.rglob("actions/**/*.yaml") |
| 86 | + ] |
| 87 | + ) |
| 88 | + |
| 89 | + approved_patterns_yaml = load_yaml(Path(approved_patterns_file)) |
| 90 | + if not isinstance(approved_patterns_yaml, list): |
| 91 | + raise ValueError( |
| 92 | + f"Approved patterns file {approved_patterns_file} must contain a list of strings, got {type(approved_patterns_yaml)}") |
| 93 | + approved_patterns: list[str] = [] |
| 94 | + for entry in approved_patterns_yaml: |
| 95 | + if not isinstance(entry, str): |
| 96 | + raise ValueError( |
| 97 | + f"Approved patterns file {approved_patterns_file} must contain a list of strings, got {type(entry)}") |
| 98 | + for e in entry.split(","): |
| 99 | + approved_patterns.append(e.strip()) |
| 100 | + print(f"There are {len(approved_patterns)} entries in the approved patterns file {approved_patterns_file}:") |
| 101 | + for p in sorted(approved_patterns): |
| 102 | + print(f"- {p}") |
| 103 | + |
| 104 | + print(f"Found {len(yaml_files)} workflow or action YAML file(s) under {github_dir}:") |
| 105 | + failures: list[str] = [] |
| 106 | + warnings: list[str] = [] |
| 107 | + for p in yaml_files: |
| 108 | + relative_path = p.relative_to(repo_root) |
| 109 | + print(f"Checking file {relative_path}") |
| 110 | + yaml = load_yaml(p) |
| 111 | + uses_entries = list(_iter_uses_nodes(yaml)) |
| 112 | + for yaml_path, uses_value in uses_entries: |
| 113 | + matcher = re.match(re_action, uses_value) |
| 114 | + if matcher is not None: |
| 115 | + print(f" {yaml_path}: {uses_value}") |
| 116 | + |
| 117 | + if uses_value.startswith("./"): |
| 118 | + print(f" ✅ Local file reference, allowing") |
| 119 | + elif uses_value.startswith("docker://apache/"): |
| 120 | + print(f" ✅ Apache project image, allowing") |
| 121 | + # The following three are always allowed, see 'External actions' in |
| 122 | + # the Apache Infrastructure GitHub Actions Policy. |
| 123 | + elif uses_value.startswith("apache/"): |
| 124 | + print(f" ✅ Apache action reference, allowing") |
| 125 | + elif uses_value.startswith("github/"): |
| 126 | + print(f" ✅ GitHub github/* action reference, allowing") |
| 127 | + elif uses_value.startswith("actions/"): |
| 128 | + print(f" ✅ GitHub action/* action reference, allowing") |
| 129 | + else: |
| 130 | + # These should actually be failures, not warnings according to |
| 131 | + # the Apache Infrastructure GitHub Actions Policy. |
| 132 | + if uses_value.startswith("docker:"): |
| 133 | + if not re.match(re_docker_sha, uses_value): |
| 134 | + warnings.append(f"⚠️ Mandatory SHA256 digest missing for Docker action reference: {uses_value}") |
| 135 | + print(" ️⚠️ Mandatory SHA256 digest missing") |
| 136 | + elif not re.match(re_action_hash, uses_value): |
| 137 | + warnings.append(f"⚠️ Mandatory Git Commit ID missing for action reference: {uses_value}") |
| 138 | + print(" ️⚠️ Mandatory Git Commit ID digest missing") |
| 139 | + |
| 140 | + approved = False |
| 141 | + blocked = False |
| 142 | + for pattern in approved_patterns: |
| 143 | + blocked = pattern.startswith("!") |
| 144 | + if blocked: |
| 145 | + pattern = pattern[1:] |
| 146 | + matches = fnmatch.fnmatch(uses_value, pattern) |
| 147 | + if matches: |
| 148 | + if blocked: |
| 149 | + approved = False |
| 150 | + break |
| 151 | + approved = True |
| 152 | + if approved: |
| 153 | + print(f" ✅ Approved pattern") |
| 154 | + elif blocked: |
| 155 | + print(f" ❌ Action is explicitly blocked") |
| 156 | + failures.append(f"❌ {relative_path} {yaml_path}: '{uses_value}' is explicitly blocked") |
| 157 | + else: |
| 158 | + print(f" ❌ Not approved") |
| 159 | + failures.append(f"❌ {relative_path} {yaml_path}: '{uses_value}' is not approved") |
| 160 | + |
| 161 | + if on_gha(): |
| 162 | + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: |
| 163 | + f.write(f"# GitHub Actions verification result\n") |
| 164 | + f.write("\n") |
| 165 | + f.write("For more information visit the [ASF Infrastructure GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html) page\n") |
| 166 | + f.write("and the [ASF Infrastructure Actions](https://github.com/apache/infrastructure-actions) repository.\n") |
| 167 | + if len(failures) > 0: |
| 168 | + f.write("\n") |
| 169 | + f.write(f"## Failures ({len(failures)})\n") |
| 170 | + for msg in failures: |
| 171 | + f.write(f"{msg}\n\n") |
| 172 | + if len(warnings) > 0: |
| 173 | + f.write("\n") |
| 174 | + f.write(f"## Warnings ({len(warnings)})\n") |
| 175 | + for msg in warnings: |
| 176 | + f.write(f"{msg}\n\n") |
| 177 | + if len(failures) == 0: |
| 178 | + f.write(f"✅ Success, all action usages match the currently approved patterns.\n") |
| 179 | + |
| 180 | + if len(failures) > 0: |
| 181 | + raise Exception(f"One or more action references are not approved or explicitly blocked:\n{"\n".join(failures)}") |
| 182 | + |
| 183 | + |
| 184 | +def run_main(args: list[str]): |
| 185 | + approved_patterns_file = Path(os.getcwd()) / "approved_patterns.yml" |
| 186 | + if len(args) > 0: |
| 187 | + check_path = args[0] |
| 188 | + if len(args) > 1: |
| 189 | + approved_patterns_file = args[1] |
| 190 | + else: |
| 191 | + check_path = Path(os.getcwd()) |
| 192 | + check_project_actions(check_path, approved_patterns_file) |
| 193 | + |
| 194 | + |
| 195 | +if __name__ == "__main__": |
| 196 | + run_main(sys.argv[1:]) |
0 commit comments