Skip to content

Commit 87ca552

Browse files
authored
Merge pull request #743 from apache/verify-action-binary-download-check
verify-action-build: add binary download verification check
2 parents 0abd7db + f2cc7a7 commit 87ca552

5 files changed

Lines changed: 753 additions & 13 deletions

File tree

utils/tests/verify_action_build/test_security.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from unittest import mock
2020

2121
from verify_action_build.security import (
22+
analyze_binary_downloads,
23+
analyze_binary_downloads_recursive,
2224
analyze_dockerfile,
2325
analyze_scripts,
2426
analyze_action_metadata,
@@ -184,6 +186,236 @@ def test_secret_default_warns(self):
184186
assert any("secret" in w for w in warnings)
185187

186188

189+
class TestAnalyzeBinaryDownloads:
190+
def _mock_fetch(self, files: dict):
191+
def fetch(org, repo, commit, path):
192+
return files.get(path)
193+
return fetch
194+
195+
def test_no_files_no_results(self):
196+
with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None):
197+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
198+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
199+
assert warnings == []
200+
assert failures == []
201+
202+
def test_pipe_to_shell_fails(self):
203+
files = {
204+
"Dockerfile": (
205+
"FROM alpine@sha256:abc\n"
206+
"RUN curl -fsSL https://example.com/install.sh | sh\n"
207+
),
208+
}
209+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
210+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
211+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
212+
assert len(failures) >= 1
213+
assert any("install.sh" in f for f in failures)
214+
215+
def test_curl_binary_without_verification_fails(self):
216+
files = {
217+
"Dockerfile": (
218+
"FROM alpine@sha256:abc\n"
219+
"RUN curl -fsSLO https://example.com/tool.tar.gz && tar xf tool.tar.gz\n"
220+
),
221+
}
222+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
223+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
224+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
225+
assert len(failures) >= 1
226+
assert any("tool.tar.gz" in f for f in failures)
227+
228+
def test_sha256sum_verification_passes(self):
229+
files = {
230+
"Dockerfile": (
231+
"FROM alpine@sha256:abc\n"
232+
"RUN curl -fsSLO https://example.com/tool.tar.gz \\\n"
233+
" && echo 'abc123deadbeefcafef00d tool.tar.gz' | sha256sum -c\n"
234+
),
235+
}
236+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
237+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
238+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
239+
assert failures == []
240+
241+
def test_gpg_verify_passes(self):
242+
files = {
243+
"Dockerfile": (
244+
"FROM alpine@sha256:abc\n"
245+
"RUN curl -fsSLO https://example.com/tool.tar.gz \\\n"
246+
" && curl -fsSLO https://example.com/tool.tar.gz.sig \\\n"
247+
" && gpg --verify tool.tar.gz.sig tool.tar.gz\n"
248+
),
249+
}
250+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
251+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
252+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
253+
assert failures == []
254+
255+
def test_cosign_verify_passes(self):
256+
files = {
257+
"Dockerfile": (
258+
"FROM alpine@sha256:abc\n"
259+
"RUN curl -fsSLO https://example.com/tool.tar.gz && cosign verify-blob tool.tar.gz\n"
260+
),
261+
}
262+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
263+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
264+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
265+
assert failures == []
266+
267+
def test_apk_add_not_flagged(self):
268+
files = {
269+
"Dockerfile": (
270+
"FROM alpine@sha256:abc\n"
271+
"RUN apk add --no-cache curl wget bash\n"
272+
),
273+
}
274+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
275+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
276+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
277+
assert failures == []
278+
279+
def test_pip_install_not_flagged(self):
280+
files = {
281+
"Dockerfile": (
282+
"FROM python:3.12@sha256:abc\n"
283+
"RUN pip install requests==2.31.0\n"
284+
),
285+
}
286+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
287+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
288+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
289+
assert failures == []
290+
291+
def test_dockerfile_add_url_fails(self):
292+
files = {
293+
"Dockerfile": (
294+
"FROM alpine@sha256:abc\n"
295+
"ADD https://example.com/app.tar.gz /app.tar.gz\n"
296+
),
297+
}
298+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
299+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
300+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
301+
assert len(failures) >= 1
302+
303+
def test_action_yml_run_block_unverified_fails(self):
304+
action_yml = """\
305+
name: Test
306+
runs:
307+
using: composite
308+
steps:
309+
- name: Download tool
310+
shell: bash
311+
run: |
312+
curl -fsSLO https://example.com/tool.tar.gz
313+
tar xf tool.tar.gz
314+
"""
315+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml):
316+
with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None):
317+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
318+
assert len(failures) >= 1
319+
320+
def test_action_yml_run_block_with_verification_passes(self):
321+
action_yml = """\
322+
name: Test
323+
runs:
324+
using: composite
325+
steps:
326+
- name: Download and verify
327+
shell: bash
328+
run: |
329+
curl -fsSLO https://example.com/tool.tar.gz
330+
echo "abc123deadbeef tool.tar.gz" | sha256sum -c
331+
tar xf tool.tar.gz
332+
"""
333+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml):
334+
with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None):
335+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
336+
assert failures == []
337+
338+
def test_releases_download_path_flagged(self):
339+
# URL without a binary extension but under /releases/download/ still
340+
# looks like a binary artefact — flag it.
341+
files = {
342+
"Dockerfile": (
343+
"FROM alpine@sha256:abc\n"
344+
"RUN curl -fsSLo /usr/local/bin/mytool "
345+
"https://github.com/x/y/releases/download/v1/mytool-linux-amd64 "
346+
"&& chmod +x /usr/local/bin/mytool\n"
347+
),
348+
}
349+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)):
350+
with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None):
351+
warnings, failures = analyze_binary_downloads("org", "repo", "a" * 40)
352+
assert len(failures) >= 1
353+
354+
355+
class TestAnalyzeBinaryDownloadsRecursive:
356+
def test_recurses_through_composite(self):
357+
# Root is a composite action that uses an unpinned-looking hash-pinned
358+
# nested action; nested action has an unverified download.
359+
root_yml = """\
360+
name: Root
361+
runs:
362+
using: composite
363+
steps:
364+
- uses: other/helper@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
365+
"""
366+
nested_yml = """\
367+
name: Helper
368+
runs:
369+
using: composite
370+
steps:
371+
- name: Download
372+
shell: bash
373+
run: |
374+
curl -fsSLO https://example.com/tool.tar.gz
375+
"""
376+
377+
def fake_action_yml(org, repo, commit, sub_path=""):
378+
if org == "myorg":
379+
return root_yml
380+
if org == "other":
381+
return nested_yml
382+
return None
383+
384+
def fake_file(org, repo, commit, path):
385+
return None
386+
387+
with mock.patch("verify_action_build.security.fetch_action_yml", side_effect=fake_action_yml):
388+
with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=fake_file):
389+
warnings, failures = analyze_binary_downloads_recursive(
390+
"myorg", "rootrepo", "b" * 40,
391+
)
392+
assert len(failures) >= 1
393+
assert any("tool.tar.gz" in f for f in failures)
394+
395+
def test_skips_trusted_org(self):
396+
# Nested uses: actions/checkout — should be skipped (trusted), so no
397+
# recursion into it even if it had downloads.
398+
root_yml = """\
399+
name: Root
400+
runs:
401+
using: composite
402+
steps:
403+
- uses: actions/checkout@cccccccccccccccccccccccccccccccccccccccc
404+
"""
405+
406+
def fake_action_yml(org, repo, commit, sub_path=""):
407+
if org == "myorg":
408+
return root_yml
409+
raise AssertionError(f"should not fetch nested yml for {org}/{repo}")
410+
411+
with mock.patch("verify_action_build.security.fetch_action_yml", side_effect=fake_action_yml):
412+
with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None):
413+
warnings, failures = analyze_binary_downloads_recursive(
414+
"myorg", "rootrepo", "b" * 40,
415+
)
416+
assert failures == []
417+
418+
187419
class TestAnalyzeRepoMetadata:
188420
def test_mit_license_detected(self):
189421
def fetch(org, repo, commit, path):

utils/verify_action_build/cli.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,22 @@ def main() -> None:
8282
action="store_true",
8383
help="Show Docker build step summary on successful builds (always shown on failure)",
8484
)
85+
parser.add_argument(
86+
"--no-binary-download-check",
87+
action="store_true",
88+
help=(
89+
"Disable the binary download verification check (enabled by default). "
90+
"The check scans Dockerfiles, run: blocks and referenced scripts for "
91+
"curl/wget/ADD downloads of binaries or scripts and fails the run if "
92+
"the file has no detectable checksum/signature verification."
93+
),
94+
)
8595
args = parser.parse_args()
8696

8797
ci_mode = args.ci
8898
cache = not args.no_cache
8999
show_build_steps = args.show_build_steps
100+
check_binary_downloads = not args.no_binary_download_check
90101

91102
if not shutil.which("docker"):
92103
console.print("[red]Error:[/red] docker is required but not found in PATH")
@@ -117,12 +128,26 @@ def main() -> None:
117128
_exit(1)
118129
for ref in action_refs:
119130
console.print(f" Extracted action reference from PR #{args.from_pr}: [bold]{ref}[/bold]")
120-
passed = all(verify_single_action(ref, gh=gh, ci_mode=ci_mode, cache=cache, show_build_steps=show_build_steps) for ref in action_refs)
131+
passed = all(
132+
verify_single_action(
133+
ref, gh=gh, ci_mode=ci_mode, cache=cache,
134+
show_build_steps=show_build_steps,
135+
check_binary_downloads=check_binary_downloads,
136+
)
137+
for ref in action_refs
138+
)
121139
_exit(0 if passed else 1)
122140
elif args.check_dependabot_prs:
123-
check_dependabot_prs(gh=gh, cache=cache, show_build_steps=show_build_steps)
141+
check_dependabot_prs(
142+
gh=gh, cache=cache, show_build_steps=show_build_steps,
143+
check_binary_downloads=check_binary_downloads,
144+
)
124145
elif args.action_ref:
125-
passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode, cache=cache, show_build_steps=show_build_steps)
146+
passed = verify_single_action(
147+
args.action_ref, gh=gh, ci_mode=ci_mode, cache=cache,
148+
show_build_steps=show_build_steps,
149+
check_binary_downloads=check_binary_downloads,
150+
)
126151
_exit(0 if passed else 1)
127152
else:
128153
parser.print_help()

utils/verify_action_build/dependabot.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ def get_gh_user(gh: GitHubClient | None = None) -> str:
3535
return gh.get_authenticated_user()
3636

3737

38-
def check_dependabot_prs(gh: GitHubClient, cache: bool = True, show_build_steps: bool = False) -> None:
38+
def check_dependabot_prs(
39+
gh: GitHubClient, cache: bool = True, show_build_steps: bool = False,
40+
check_binary_downloads: bool = True,
41+
) -> None:
3942
"""List open dependabot PRs, verify each, and optionally merge."""
4043
console.print()
4144
console.rule("[bold]Dependabot PR Review[/bold]")
@@ -158,10 +161,17 @@ def check_dependabot_prs(gh: GitHubClient, cache: bool = True, show_build_steps:
158161
sub_ref = f"{org_repo}/{sp}@{commit_hash}"
159162
else:
160163
sub_ref = f"{org_repo}@{commit_hash}"
161-
if not verify_single_action(sub_ref, gh=gh, cache=cache, show_build_steps=show_build_steps):
164+
if not verify_single_action(
165+
sub_ref, gh=gh, cache=cache, show_build_steps=show_build_steps,
166+
check_binary_downloads=check_binary_downloads,
167+
):
162168
passed = False
163169
else:
164-
if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh, cache=cache, show_build_steps=show_build_steps):
170+
if not verify_single_action(
171+
f"{org_repo}@{commit_hash}", gh=gh, cache=cache,
172+
show_build_steps=show_build_steps,
173+
check_binary_downloads=check_binary_downloads,
174+
):
165175
passed = False
166176

167177
if not passed:

0 commit comments

Comments
 (0)