Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/verify_dependabot_action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ jobs:
- run: pipx install uv

- name: Verify action build
run: |
uv run utils/verify-action-build.py --ci --from-pr ${{ github.event.pull_request.number }}
run: uv run utils/verify-action-build.py --ci --from-pr "${{ github.event.pull_request.number }}"
env:
GH_TOKEN: ${{ github.token }}
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ The script will:

A clean result confirms that the compiled JS was built from the declared source. Any differences will be flagged for manual inspection.

#### Security Review Checklist

When reviewing an action (new or updated), watch for these potential issues in the source diff between the approved and new versions:

- **Credential exfiltration**: code that reads secrets, tokens, or environment variables (e.g. `GITHUB_TOKEN`, `AWS_*`, `ACTIONS_RUNTIME_TOKEN`) and sends them to external endpoints via `fetch`, `http`, `net`, or shell commands (`curl`, `wget`).
- **Arbitrary code execution**: use of `eval()`, `new Function()`, `child_process.exec/spawn` with unsanitised inputs, or downloading and running scripts from remote URLs at build or runtime.
- **Unexpected network calls**: outbound requests to domains unrelated to the action's stated purpose, especially in `post` or cleanup steps that run after the main action.
- **Workflow permission escalation**: actions that request or rely on elevated permissions (`contents: write`, `id-token: write`, `packages: write`) beyond what their functionality requires.
- **Supply-chain risks**: new or changed dependencies in `package.json` that are unpopular, recently published, or have been involved in known compromises; mismatches between `package-lock.json` and `package.json`.
- **Obfuscated code**: hex-encoded strings, base64 blobs, or intentionally unreadable code in source files (not in compiled `dist/`).
- **File-system tampering**: writing to locations outside the workspace (`$GITHUB_WORKSPACE`), modifying `$GITHUB_ENV`, `$GITHUB_PATH`, or `$GITHUB_OUTPUT` in unexpected ways to influence subsequent workflow steps.
- **Compiled JS mismatch**: any unexplained diff between the published `dist/` and a clean rebuild — this is the primary check the verification script performs.

For the full approval policy and requirements, see the [ASF GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html).

#### Batch-Reviewing Dependabot PRs

To review all open dependabot PRs at once, run:
Expand Down
36 changes: 24 additions & 12 deletions utils/verify-action-build.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@

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
Expand All @@ -54,14 +57,15 @@
from rich.table import Table
from rich.text import Text

_force_color = os.environ.get("CI") is not None
console = Console(stderr=True, force_terminal=_force_color)
output = Console(force_terminal=_force_color)
_is_ci = os.environ.get("CI") is not None
console = Console(stderr=True, force_terminal=_is_ci, force_interactive=not _is_ci if _is_ci else None)
output = Console(force_terminal=_is_ci)

# 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:
Expand Down Expand Up @@ -1314,16 +1318,18 @@ def verify_single_action(action_ref: str, gh: GitHubClient | None = None, ci_mod
)

console.print()
checklist_hint = f"\n[dim]Security review checklist: {SECURITY_CHECKLIST_URL}[/dim]"
if all_match:
if is_js_action:
result_msg = "[green bold]All compiled JavaScript matches the rebuild[/green bold]"
else:
result_msg = f"[green bold]{action_type} action — no compiled JS to verify[/green bold]"
console.print(Panel(result_msg, border_style="green", title="RESULT"))
console.print(Panel(result_msg + checklist_hint, border_style="green", title="RESULT"))
else:
console.print(
Panel(
"[red bold]Differences detected between published and rebuilt JS[/red bold]",
"[red bold]Differences detected between published and rebuilt JS[/red bold]"
+ checklist_hint,
border_style="red",
title="RESULT",
)
Expand Down Expand Up @@ -1577,10 +1583,16 @@ def check_dependabot_prs(gh: GitHubClient) -> None:
)


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",
Expand Down Expand Up @@ -1619,7 +1631,7 @@ def main() -> None:

if not shutil.which("docker"):
console.print("[red]Error:[/red] docker is required but not found in PATH")
sys.exit(1)
_exit(1)

# Build the GitHub client
if args.no_gh:
Expand All @@ -1628,34 +1640,34 @@ def main() -> None:
"[red]Error:[/red] --no-gh requires a GitHub token. "
"Pass --github-token TOKEN or set the GITHUB_TOKEN environment variable."
)
sys.exit(1)
_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."
)
sys.exit(1)
_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}")
sys.exit(1)
_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) for ref in action_refs)
sys.exit(0 if passed else 1)
_exit(0 if passed else 1)
elif args.check_dependabot_prs:
check_dependabot_prs(gh=gh)
elif args.action_ref:
passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode)
sys.exit(0 if passed else 1)
_exit(0 if passed else 1)
else:
parser.print_help()
sys.exit(1)
_exit(1)


if __name__ == "__main__":
Expand Down
Loading