-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_check_pin_freshness.py
More file actions
286 lines (221 loc) · 10.3 KB
/
Copy pathtest_check_pin_freshness.py
File metadata and controls
286 lines (221 loc) · 10.3 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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
"""Tests for `.github/scripts/check_pin_freshness.py` (#136).
Mocks `_fetch_json` (the only network seam) and exercises:
- Tag pin resolution (lightweight + annotated tag).
- SHA pin re-tag detection (upstream tag now points at a different SHA).
- 404 / network failure handling (warn, never fail).
- Strict mode (`PIN_FRESHNESS_STRICT=1`) escalates warnings to failures.
- `GITHUB_OUTPUT` integration writes `findings_count` for the workflow.
- `GITHUB_TOKEN` missing → exit 2 (script-level error).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
if TYPE_CHECKING:
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_PATH = REPO_ROOT / ".github" / "scripts" / "check_pin_freshness.py"
def _load_script() -> Any:
spec = importlib.util.spec_from_file_location("check_pin_freshness", SCRIPT_PATH)
if spec is None or spec.loader is None:
msg = f"Could not load script at {SCRIPT_PATH}"
raise RuntimeError(msg)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
cpf = _load_script()
def _make_ref(action: str, pin: str, comment: str | None = None) -> Any:
"""Build a minimal ActionRef-shaped object for the freshness checks."""
return cpf._pins.ActionRef(
file=Path("fake.yml"), line=1, action=action, pin=pin, comment=comment
)
# ---------- _resolve_tag_sha ----------
def test_resolve_lightweight_tag() -> None:
"""Lightweight tag → object.type == 'commit', sha is the commit SHA."""
ref_payload = {"object": {"type": "commit", "sha": "abc" * 13 + "abcd"}}
with patch.object(cpf, "_fetch_json", return_value=ref_payload):
assert cpf._resolve_tag_sha("foo/bar", "v1.0.0", "fake") == "abc" * 13 + "abcd"
def test_resolve_annotated_tag() -> None:
"""Annotated tag → two GETs; second dereferences the tag object to commit."""
ref_payload = {"object": {"type": "tag", "sha": "tagobj_sha"}}
tag_payload = {"object": {"sha": "commit_sha"}}
with patch.object(cpf, "_fetch_json", side_effect=[ref_payload, tag_payload]):
assert cpf._resolve_tag_sha("foo/bar", "v1.0.0", "fake") == "commit_sha"
def test_resolve_returns_none_on_404() -> None:
"""`_fetch_json` returning None propagates as None — no crash."""
with patch.object(cpf, "_fetch_json", return_value=None):
assert cpf._resolve_tag_sha("foo/bar", "v9.9.9", "fake") is None
def test_resolve_returns_none_on_malformed_payload() -> None:
"""Missing object / non-string sha → None (defensive)."""
with patch.object(cpf, "_fetch_json", return_value={"unrelated": "shape"}):
assert cpf._resolve_tag_sha("foo/bar", "v1.0.0", "fake") is None
# ---------- _action_repo (sub-path normalisation) ----------
def test_action_repo_passthrough_for_owner_repo() -> None:
assert cpf._action_repo("actions/checkout") == "actions/checkout"
def test_action_repo_strips_subpath() -> None:
"""`github/codeql-action/init` → `github/codeql-action` (subpath isn't a repo)."""
assert cpf._action_repo("github/codeql-action/init") == "github/codeql-action"
def test_action_repo_strips_deep_subpath() -> None:
"""Deeply nested sub-actions still strip back to owner/repo."""
assert cpf._action_repo("owner/repo/path/to/sub-action") == "owner/repo"
def test_resolve_tag_sha_uses_owner_repo_for_subpath_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression for the false-positive 404 on sub-path actions.
Before this fix, _resolve_tag_sha passed `github/codeql-action/init` as
the API path segment, hitting `/repos/github/codeql-action/init/...`
which 404s (init is a tree path, not a repo). The audit then reported
`init@v4 — upstream tag no longer resolves` even though `v4` resolves
fine on `github/codeql-action`.
"""
seen_urls: list[str] = []
def fake_fetch(url: str, _token: str) -> dict[str, object] | None:
seen_urls.append(url)
return {"object": {"type": "commit", "sha": "deadbeef" * 5}}
monkeypatch.setattr(cpf, "_fetch_json", fake_fetch)
sha = cpf._resolve_tag_sha("github/codeql-action/init", "v4", "fake")
assert sha == "deadbeef" * 5
assert (
seen_urls[0]
== "https://api.github.com/repos/github/codeql-action/git/refs/tags/v4"
), seen_urls
# ---------- _check_tag_pin ----------
def test_tag_pin_passes_when_resolved() -> None:
ref = _make_ref("actions/checkout", "v4")
with patch.object(cpf, "_resolve_tag_sha", return_value="some_sha"):
assert cpf._check_tag_pin(ref, "fake") is None
def test_tag_pin_warns_when_unresolved() -> None:
ref = _make_ref("astral-sh/setup-uv", "v5")
with patch.object(cpf, "_resolve_tag_sha", return_value=None):
message = cpf._check_tag_pin(ref, "fake")
assert message is not None
assert "v5" in message
assert "no longer resolves" in message
# ---------- _check_sha_pin ----------
def test_sha_pin_passes_when_tag_still_resolves_to_pin() -> None:
sha = "a" * 40
ref = _make_ref("aquasecurity/trivy-action", sha, comment="# v0.36.0")
with patch.object(cpf, "_resolve_tag_sha", return_value=sha):
assert cpf._check_sha_pin(ref, "fake") is None
def test_sha_pin_warns_on_retag() -> None:
"""Upstream re-tag: same tag now resolves to a different SHA."""
pinned = "a" * 40
upstream = "b" * 40
ref = _make_ref("aquasecurity/trivy-action", pinned, comment="# v0.36.0")
with patch.object(cpf, "_resolve_tag_sha", return_value=upstream):
message = cpf._check_sha_pin(ref, "fake")
assert message is not None
assert "re-tagged" in message
assert "v0.36.0" in message
def test_sha_pin_warns_when_documented_tag_404() -> None:
sha = "a" * 40
ref = _make_ref("aquasecurity/trivy-action", sha, comment="# v0.36.0")
with patch.object(cpf, "_resolve_tag_sha", return_value=None):
message = cpf._check_sha_pin(ref, "fake")
assert message is not None
assert "no longer resolves" in message
def test_sha_pin_silent_without_comment() -> None:
"""Missing comment is the shape audit's job, not freshness."""
sha = "a" * 40
ref = _make_ref("aquasecurity/trivy-action", sha, comment=None)
assert cpf._check_sha_pin(ref, "fake") is None
# ---------- main() ----------
def _setup_workflow_dir(tmp_path: Path) -> Path:
workflows = tmp_path / "workflows"
workflows.mkdir()
(workflows / "ci.yml").write_text(
"jobs:\n j:\n steps:\n"
" - uses: actions/checkout@v4\n"
" - uses: aquasecurity/trivy-action@" + ("a" * 40) + " # v0.36.0\n",
encoding="utf-8",
)
# `tmp_path / "actions"` deliberately not created — exercises the
# optional-dir branch in `_collect_yaml_files`.
return workflows
def test_main_exits_2_without_token(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
assert cpf.main() == 2
assert "GITHUB_TOKEN required" in capsys.readouterr().out
def test_main_warns_not_fails_in_default_mode(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Stale tag → ::warning::, exit 0 (gate not a tripwire)."""
workflows = _setup_workflow_dir(tmp_path)
monkeypatch.setenv("GITHUB_TOKEN", "fake")
monkeypatch.delenv("PIN_FRESHNESS_STRICT", raising=False)
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with (
patch.object(cpf._pins, "WORKFLOWS_DIR", workflows),
patch.object(cpf._pins, "ACTIONS_DIR", tmp_path / "no-such"),
# checkout@v4 resolves; trivy SHA's documented tag has been re-tagged.
patch.object(
cpf,
"_resolve_tag_sha",
side_effect=["abc" * 13 + "abcd", "b" * 40],
),
):
assert cpf.main() == 0
out = capsys.readouterr().out
assert "::warning" in out
assert "re-tagged" in out
def test_main_strict_mode_fails_on_finding(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
workflows = _setup_workflow_dir(tmp_path)
monkeypatch.setenv("GITHUB_TOKEN", "fake")
monkeypatch.setenv("PIN_FRESHNESS_STRICT", "1")
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with (
patch.object(cpf._pins, "WORKFLOWS_DIR", workflows),
patch.object(cpf._pins, "ACTIONS_DIR", tmp_path / "no-such"),
patch.object(
cpf,
"_resolve_tag_sha",
side_effect=["abc" * 13 + "abcd", None],
),
):
assert cpf.main() == 1
assert "::error" in capsys.readouterr().out
def test_main_writes_findings_count_to_github_output(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`$GITHUB_OUTPUT` integration so the calling workflow can branch on it."""
workflows = _setup_workflow_dir(tmp_path)
output_file = tmp_path / "github_output"
output_file.write_text("", encoding="utf-8")
monkeypatch.setenv("GITHUB_TOKEN", "fake")
monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
monkeypatch.delenv("PIN_FRESHNESS_STRICT", raising=False)
with (
patch.object(cpf._pins, "WORKFLOWS_DIR", workflows),
patch.object(cpf._pins, "ACTIONS_DIR", tmp_path / "no-such"),
patch.object(
cpf,
"_resolve_tag_sha",
side_effect=["abc" * 13 + "abcd", "b" * 40],
),
):
assert cpf.main() == 0
assert "findings_count=1" in output_file.read_text(encoding="utf-8")
def test_main_passes_clean_when_all_resolve(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
workflows = _setup_workflow_dir(tmp_path)
monkeypatch.setenv("GITHUB_TOKEN", "fake")
monkeypatch.delenv("PIN_FRESHNESS_STRICT", raising=False)
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
pin_sha = "a" * 40
with (
patch.object(cpf._pins, "WORKFLOWS_DIR", workflows),
patch.object(cpf._pins, "ACTIONS_DIR", tmp_path / "no-such"),
patch.object(cpf, "_resolve_tag_sha", side_effect=["x" * 40, pin_sha]),
):
assert cpf.main() == 0
out = capsys.readouterr().out
assert "0 finding(s)" in out
assert "::warning" not in out