diff --git a/.github/workflows/verify_dependabot_action.yml b/.github/workflows/verify_dependabot_action.yml index f1830f65..5b0850a4 100644 --- a/.github/workflows/verify_dependabot_action.yml +++ b/.github/workflows/verify_dependabot_action.yml @@ -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 }} diff --git a/README.md b/README.md index df3375df..c8c7bdff 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/utils/verify-action-build.py b/utils/verify-action-build.py index 1850a41e..fb235216 100644 --- a/utils/verify-action-build.py +++ b/utils/verify-action-build.py @@ -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 @@ -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: @@ -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", ) @@ -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", @@ -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: @@ -1628,7 +1640,7 @@ 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"): @@ -1636,26 +1648,26 @@ def main() -> None: "[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__":