-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_action_pins.py
More file actions
245 lines (201 loc) · 8.62 KB
/
Copy pathcheck_action_pins.py
File metadata and controls
245 lines (201 loc) · 8.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
#!/usr/bin/env python3
"""Audit GitHub Actions pin shapes against the project policy.
Policy from `docs/DEVELOPMENT.md#action-pinning-policy`:
- **First-party** (`actions/*`, `github/*`) — pin to **major tag**
(`@v\\d+`). SHA + trailing `# vN.M.P` comment is also accepted (stricter
posture; used in elevated-permissions workflows like `release.yml`).
- **`astral-sh/setup-uv`** — pin to **latest patch tag**
(`@vN.M.P`). The maintainers do not publish a floating major tag for
the v8 series; `@v8` would not resolve. SHA + comment also accepted.
- **Third-party** (anything else, including `aquasecurity/*`, `gitleaks/*`,
`amannn/*`, `release-drafter/*`) — pin to a **40-hex-char SHA** with a
trailing `# vN.M.P` comment naming the resolved tag. A moving branch
or re-tagged release in a supply-chain workflow defeats the point of
the scan/automation.
The script walks every YAML file under `.github/workflows/`, extracts
each `uses:` line with its trailing comment if any, and validates the
pin against the policy bucket the action falls into.
Exit codes:
0 — every action invocation matches policy
1 — one or more violations
2 — script-level error (no workflow files found, parse failure)
Usage (from repo root):
python .github/scripts/check_action_pins.py
Bumping a third-party action: open a focused PR that updates the SHA
*and* the trailing comment. Dependabot's `github-actions` ecosystem
opens those PRs automatically when new SHAs land.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
WORKFLOWS_DIR = Path(".github/workflows")
# Composite actions live under `.github/actions/<name>/action.yml` and can
# carry their own `uses:` invocations of third-party actions in their
# `runs.steps` block. Walk this dir alongside workflows (#137).
ACTIONS_DIR = Path(".github/actions")
# Captures `uses: <action>@<ref>` lines, optionally with a trailing comment.
# Tolerant of the leading `- ` (list item) or no leading dash, and any
# whitespace indent.
_USES_RE = re.compile(r"^\s*-?\s*uses:\s*(?P<ref>\S+)\s*(?P<comment>#.*)?$")
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
_MAJOR_TAG_RE = re.compile(r"^v\d+$")
_PATCH_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
# A `# vN.M.P` annotation in the trailing comment. We accept partial
# semver (`# v5`, `# v4.2`) because some upstream tags are minor-only,
# but we require at least the leading `v\d+`.
_VERSION_COMMENT_RE = re.compile(r"v\d+(\.\d+)*")
@dataclass(frozen=True)
class ActionRef:
"""One `uses:` invocation: file:line, the @ref, and trailing comment."""
file: Path
line: int
action: str # part before @, e.g. "actions/checkout"
pin: str # part after @, e.g. "v4" or "11bd71..."
comment: str | None # raw trailing comment incl. `#`, or None
def loc(self) -> str:
return f"{self.file}:{self.line}"
def parse_workflow(path: Path) -> list[ActionRef]:
"""Return every action invocation in a workflow or composite-action file.
Walks `uses:` lines, including those inside composite-action `runs.steps`
blocks (#137). Skips local-path references (`uses: ./...`) — those point
at repo-internal files (reusable workflows / composite actions in the
same repo) and don't carry a third-party-action pin to validate.
"""
refs: list[ActionRef] = []
for line_num, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
match = _USES_RE.match(line)
if not match:
continue
ref_value = match.group("ref")
# Skip local-path references — these point at repo-internal files
# (reusable workflows or composite actions in this same repo) and
# don't carry an external pin shape we should validate. The path
# may have a `@ref` suffix (reusable workflows) or none (composite
# actions); either is fine.
if ref_value.startswith(("./", "../")):
continue
if "@" not in ref_value:
# Malformed — surface as a special error in validate_ref.
refs.append(
ActionRef(
file=path,
line=line_num,
action=ref_value,
pin="",
comment=match.group("comment"),
)
)
continue
action, pin = ref_value.split("@", 1)
refs.append(
ActionRef(
file=path,
line=line_num,
action=action,
pin=pin,
comment=match.group("comment"),
)
)
return refs
def classify(action: str) -> str:
"""Return the pin-shape policy bucket for a given action.
- "patch-tag" — astral-sh/setup-uv (no floating major tag)
- "major-tag" — actions/* and github/* (first-party)
- "third-party-sha" — everyone else (default for unknown)
"""
if action == "astral-sh/setup-uv":
return "patch-tag"
if action.startswith(("actions/", "github/")):
return "major-tag"
return "third-party-sha"
def validate_ref(ref: ActionRef) -> str | None:
"""Return an error message if `ref` violates policy, else None.
SHA pins are always accepted (most-strict shape) for any action,
provided the trailing comment names the resolved version. Tag pins
are accepted only for the buckets the policy documents.
"""
if not ref.pin:
return f"missing @ref on `uses: {ref.action}`"
bucket = classify(ref.action)
# SHA pins are stricter than tag pins; accept everywhere as long
# as a trailing version comment is present. The comment ties the
# opaque hash back to a human-reviewable changelog.
if _SHA_RE.match(ref.pin):
if not ref.comment or not _VERSION_COMMENT_RE.search(ref.comment):
return (
f"{ref.action}@{ref.pin[:8]}… — SHA pin missing trailing "
"`# vN.M.P` comment"
)
return None
# Tag pins — must match the bucket.
if bucket == "patch-tag":
if not _PATCH_TAG_RE.match(ref.pin):
return (
f"{ref.action}@{ref.pin} — astral-sh/setup-uv has no floating "
"major tag for the v8 series; pin to a patch tag (e.g. "
"`@v8.0.0`) or a SHA + trailing `# vN.M.P` comment"
)
return None
if bucket == "major-tag":
if not _MAJOR_TAG_RE.match(ref.pin):
return (
f"{ref.action}@{ref.pin} — first-party action requires major "
"tag (`@v4`) or SHA + trailing `# vN.M.P` comment"
)
return None
# third-party-sha — only SHA + comment is acceptable.
return (
f"{ref.action}@{ref.pin} — third-party action requires SHA pin "
"(40 hex chars) with trailing `# vN.M.P` comment, not a tag"
)
def _collect_yaml_files() -> list[Path]:
"""Workflow files + composite-action files. Composite dir is optional."""
files: list[Path] = []
files.extend(sorted(WORKFLOWS_DIR.glob("*.yml")))
files.extend(sorted(WORKFLOWS_DIR.glob("*.yaml")))
if ACTIONS_DIR.is_dir():
# `.github/actions/<name>/action.yml` (or `action.yaml`). Recursive
# glob so nested composite actions are picked up.
files.extend(sorted(ACTIONS_DIR.glob("**/action.yml")))
files.extend(sorted(ACTIONS_DIR.glob("**/action.yaml")))
return files
def main() -> int:
if not WORKFLOWS_DIR.is_dir():
print(f"::error::workflows dir not found: {WORKFLOWS_DIR}")
return 2
yml_files = _collect_yaml_files()
if not yml_files:
print(f"::error::no workflow files in {WORKFLOWS_DIR}")
return 2
refs: list[ActionRef] = []
for path in yml_files:
refs.extend(parse_workflow(path))
if not refs:
print(f"::error::no `uses:` lines found across {len(yml_files)} workflow files")
return 2
failed = False
for ref in refs:
problem = validate_ref(ref)
if problem is None:
continue
failed = True
# GitHub Actions error annotation: surfaces inline on the file in PR review.
print(f"::error file={ref.file},line={ref.line}::{problem}")
if failed:
print(
"\nSee docs/DEVELOPMENT.md#action-pinning-policy for the rule. "
"Dependabot's `github-actions` ecosystem opens SHA-bump PRs "
"automatically when new tags ship."
)
return 1
print(
f"Action pins audit OK — {len(refs)} pins, {len(yml_files)} files "
f"(workflows + composite actions)."
)
return 0
if __name__ == "__main__":
sys.exit(main())