From e3acf41631a4da614f4ae6c2e239b25a4fb509dd Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:13:39 -0500 Subject: [PATCH 1/5] ci: add GitHub Actions security audit script Adds a Python script to audit GitHub Actions workflows for common security best practices. The script checks for: 1. Explicit permissions declarations 2. No 'secrets: inherit' usage 3. Fork PR protection for jobs using secrets 4. Tag ancestry verification on release workflows 5. Workflow dispatch protection on publish/release workflows 6. Overly permissive permissions 7. Pinned action versions (no @main/@master) 8. Dangerous patterns (untrusted input, pull_request_target) Based on security hardening work applied to this repository. Run with: python scripts/audit_gha_security.py Run tests: cd scripts && python -m unittest test_audit_gha_security Co-Authored-By: Claude Opus 4.5 --- scripts/audit_gha_security.py | 330 ++++++++++++++ scripts/test_audit_gha_security.py | 684 +++++++++++++++++++++++++++++ 2 files changed, 1014 insertions(+) create mode 100755 scripts/audit_gha_security.py create mode 100644 scripts/test_audit_gha_security.py diff --git a/scripts/audit_gha_security.py b/scripts/audit_gha_security.py new file mode 100755 index 000000000..6c3ea7c84 --- /dev/null +++ b/scripts/audit_gha_security.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +""" +GitHub Actions Security Audit Script + +This script checks for common security best practices in GitHub Actions workflows. +Run from the repository root: python scripts/audit-gha-security.py + +Based on security hardening applied to posit-dev/publisher +""" + +import os +import re +import sys +from pathlib import Path +from dataclasses import dataclass, field + + +# ANSI color codes +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + NC = "\033[0m" # No Color + + +@dataclass +class AuditResult: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + successes: list[str] = field(default_factory=list) + + +def error(result: AuditResult, message: str) -> None: + result.errors.append(message) + print(f"{Colors.RED}✗ ERROR:{Colors.NC} {message}") + + +def warning(result: AuditResult, message: str) -> None: + result.warnings.append(message) + print(f"{Colors.YELLOW}⚠ WARNING:{Colors.NC} {message}") + + +def success(message: str) -> None: + print(f"{Colors.GREEN}✓{Colors.NC} {message}") + + +def info(message: str) -> None: + print(f" {message}") + + +def get_workflow_files(workflows_dir: Path) -> list[Path]: + """Get all YAML workflow files in the directory.""" + files = [] + for pattern in ["*.yaml", "*.yml"]: + files.extend(workflows_dir.glob(pattern)) + return sorted(files) + + +def check_explicit_permissions(workflows: list[Path], result: AuditResult) -> None: + """CHECK 1: All workflows should have explicit permissions.""" + print("1. Checking for explicit permissions...") + print(" (Workflows should declare minimum required permissions)") + print() + + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + # Check for top-level permissions block + if re.search(r"^permissions:", content, re.MULTILINE): + success(f"{filename}: Has explicit permissions") + else: + error(result, f"{filename}: Missing top-level 'permissions:' block") + info(" Add explicit permissions, e.g.:") + info(" permissions:") + info(" contents: read") + print() + + +def check_secrets_inherit(workflows: list[Path], result: AuditResult) -> None: + """CHECK 2: No 'secrets: inherit' usage.""" + print("2. Checking for 'secrets: inherit' usage...") + print(" (Should pass secrets explicitly to limit exposure)") + print() + + inherit_found = False + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + if "secrets: inherit" in content: + error(result, f"{filename}: Uses 'secrets: inherit' - pass secrets explicitly instead") + # Show line numbers + for i, line in enumerate(content.splitlines(), 1): + if "secrets: inherit" in line: + info(f" Line {i}: {line.strip()}") + inherit_found = True + + if not inherit_found: + success("No workflows use 'secrets: inherit'") + print() + + +def check_fork_pr_protection(workflows: list[Path], result: AuditResult) -> None: + """CHECK 3: Fork PR protection for jobs using secrets.""" + print("3. Checking for fork PR protection on secret-using jobs...") + print(" (Jobs using secrets should check: github.event.pull_request.head.repo.full_name == github.repository)") + print() + + fork_check_pattern = r"github\.event\.pull_request\.head\.repo\.full_name\s*==\s*github\.repository" + + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + # Check if workflow is triggered by pull_request (but not pull_request_target) + if re.search(r"^\s*pull_request\s*:", content, re.MULTILINE) or \ + re.search(r"^\s+- pull_request\s*$", content, re.MULTILINE): + # Check if workflow uses secrets in any form: + # - Inline: ${{ secrets.FOO }} + # - Passed to reusable workflow: secrets: (not empty) + uses_inline_secrets = "${{ secrets." in content + passes_secrets = ( + re.search(r"^\s+secrets:\s*$", content, re.MULTILINE) or + re.search(r"^\s+secrets:\s*\n\s+\w+:", content, re.MULTILINE) + ) + + if uses_inline_secrets or passes_secrets: + if re.search(fork_check_pattern, content): + success(f"{filename}: Has fork PR protection") + else: + warning(result, f"{filename}: Triggered by pull_request and passes secrets, but may lack fork PR protection") + info(" Consider adding to jobs that use secrets:") + info(" if: github.event.pull_request.head.repo.full_name == github.repository") + print() + + +def check_tag_ancestry_verification(workflows: list[Path], result: AuditResult) -> None: + """CHECK 4: Tag-triggered workflows should verify tag ancestry.""" + print("4. Checking tag-triggered workflows for ancestry verification...") + print(" (Tag-triggered releases should verify tag points to main branch)") + print() + + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + # Check if workflow is triggered by tag push + if "tags:" in content and re.search(r'["\']?v\*', content): + # Check if it verifies tag ancestry + has_verification = ( + "merge-base --is-ancestor" in content or + "verify-tag-ancestry" in content + ) + if has_verification: + success(f"{filename}: Tag-triggered workflow has ancestry verification") + else: + warning(result, f"{filename}: Tag-triggered workflow may lack ancestry verification") + info(" Consider adding verification that tag points to main branch") + print() + + +def check_workflow_dispatch_protection(workflows: list[Path], result: AuditResult) -> None: + """CHECK 5: Workflow dispatch on sensitive workflows.""" + print("5. Checking for workflow_dispatch on sensitive workflows...") + print(" (Publish/release workflows should not allow manual dispatch without protection)") + print() + + sensitive_patterns = re.compile(r"publish|release", re.IGNORECASE) + + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + if sensitive_patterns.search(filename): + if "workflow_dispatch" in content: + # Check for protection mechanisms + has_protection = ( + "environment:" in content or + "merge-base --is-ancestor" in content + ) + if has_protection: + success(f"{filename}: Has workflow_dispatch with protection") + else: + warning(result, f"{filename}: Publish/release workflow has workflow_dispatch without visible protection") + info(" Consider removing workflow_dispatch or adding environment protection") + else: + success(f"{filename}: No workflow_dispatch (good for release workflows)") + print() + + +def check_overly_permissive(workflows: list[Path], result: AuditResult) -> None: + """CHECK 6: Overly permissive permissions.""" + print("6. Checking for overly permissive permissions...") + print() + + checked_any = False + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + # Check for write-all + if "permissions: write-all" in content: + error(result, f"{filename}: Uses 'permissions: write-all' - too permissive") + checked_any = True + + # Check for contents: write on non-release workflows + if "contents: write" in content: + if not re.search(r"release|license|publish", filename, re.IGNORECASE): + warning(result, f"{filename}: Has 'contents: write' - verify this is necessary") + checked_any = True + + if not checked_any: + success("No overly permissive permissions found") + print() + + +def check_unpinned_actions(workflows: list[Path], result: AuditResult) -> None: + """CHECK 7: Pinned action versions.""" + print("7. Checking for unpinned action versions...") + print(" (Actions should use specific versions, not @master or @main)") + print() + + unpinned_pattern = re.compile(r"uses:\s+([^/]+/[^@]+)@(master|main)\s*$", re.MULTILINE) + unpinned_found = False + + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + matches = unpinned_pattern.findall(content) + if matches: + warning(result, f"{filename}: Uses actions pinned to master/main branch") + for action, branch in matches: + info(f" {action}@{branch}") + unpinned_found = True + + if not unpinned_found: + success("No actions pinned to master/main branches found") + print() + + +def check_dangerous_patterns(workflows: list[Path], result: AuditResult) -> None: + """CHECK 8: Dangerous patterns.""" + print("8. Checking for dangerous patterns...") + print() + + # Pattern for untrusted input in expressions + untrusted_input_pattern = re.compile( + r"\$\{\{\s*github\.event\.(issue|pull_request|comment)\.(body|title)" + ) + + dangerous_found = False + + for workflow in workflows: + content = workflow.read_text() + filename = workflow.name + + # Check for direct use of untrusted input + if untrusted_input_pattern.search(content): + error(result, f"{filename}: Potentially uses untrusted input directly (possible injection)") + dangerous_found = True + + # Check for pull_request_target with checkout of PR code + if "pull_request_target" in content: + if "actions/checkout" in content: + # Check if it checks out PR head + if re.search(r"ref:.*\$\{\{.*pull_request", content): + error(result, f"{filename}: Uses pull_request_target with PR checkout - high risk pattern") + dangerous_found = True + else: + warning(result, f"{filename}: Uses pull_request_target - review carefully") + dangerous_found = True + + if not dangerous_found: + success("No obviously dangerous patterns found") + print() + + +def main() -> int: + """Run all security checks.""" + workflows_dir = Path(".github/workflows") + + print("=" * 40) + print("GitHub Actions Security Audit") + print("=" * 40) + print() + + if not workflows_dir.is_dir(): + print(f"{Colors.RED}✗ ERROR:{Colors.NC} No .github/workflows directory found") + return 1 + + workflows = get_workflow_files(workflows_dir) + if not workflows: + print(f"{Colors.RED}✗ ERROR:{Colors.NC} No workflow files found") + return 1 + + result = AuditResult() + + # Run all checks + check_explicit_permissions(workflows, result) + check_secrets_inherit(workflows, result) + check_fork_pr_protection(workflows, result) + check_tag_ancestry_verification(workflows, result) + check_workflow_dispatch_protection(workflows, result) + check_overly_permissive(workflows, result) + check_unpinned_actions(workflows, result) + check_dangerous_patterns(workflows, result) + + # Summary + print("=" * 40) + print("Audit Summary") + print("=" * 40) + print(f"Errors: {Colors.RED}{len(result.errors)}{Colors.NC}") + print(f"Warnings: {Colors.YELLOW}{len(result.warnings)}{Colors.NC}") + print() + + if result.errors: + print(f"{Colors.RED}Security issues found that should be addressed.{Colors.NC}") + return 1 + elif result.warnings: + print(f"{Colors.YELLOW}Some warnings found - review recommended.{Colors.NC}") + return 0 + else: + print(f"{Colors.GREEN}All checks passed!{Colors.NC}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_audit_gha_security.py b/scripts/test_audit_gha_security.py new file mode 100644 index 000000000..a320a41b8 --- /dev/null +++ b/scripts/test_audit_gha_security.py @@ -0,0 +1,684 @@ +#!/usr/bin/env python3 +""" +Tests for the GitHub Actions Security Audit Script. + +Run with: python scripts/test_audit_gha_security.py +""" + +import sys +import tempfile +import unittest +from io import StringIO +from pathlib import Path + +from audit_gha_security import ( + AuditResult, + check_explicit_permissions, + check_secrets_inherit, + check_fork_pr_protection, + check_tag_ancestry_verification, + check_workflow_dispatch_protection, + check_overly_permissive, + check_unpinned_actions, + check_dangerous_patterns, + get_workflow_files, +) + + +def create_workflow(directory: Path, name: str, content: str) -> Path: + """Helper to create a workflow file.""" + filepath = directory / name + filepath.write_text(content) + return filepath + + +class TestGetWorkflowFiles(unittest.TestCase): + def test_finds_yaml_files(self): + with tempfile.TemporaryDirectory() as tmpdir: + temp_dir = Path(tmpdir) + create_workflow(temp_dir, "test.yaml", "name: Test") + create_workflow(temp_dir, "test2.yml", "name: Test2") + + files = get_workflow_files(temp_dir) + + self.assertEqual(len(files), 2) + self.assertTrue(any(f.name == "test.yaml" for f in files)) + self.assertTrue(any(f.name == "test2.yml" for f in files)) + + def test_ignores_non_yaml_files(self): + with tempfile.TemporaryDirectory() as tmpdir: + temp_dir = Path(tmpdir) + create_workflow(temp_dir, "test.yaml", "name: Test") + create_workflow(temp_dir, "readme.md", "# Readme") + + files = get_workflow_files(temp_dir) + + self.assertEqual(len(files), 1) + self.assertEqual(files[0].name, "test.yaml") + + +class TestExplicitPermissions(unittest.TestCase): + def test_passes_with_permissions(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "good.yaml", + """name: Good +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + # Capture stdout + captured = StringIO() + sys.stdout = captured + check_explicit_permissions([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 0) + self.assertIn("Has explicit permissions", captured.getvalue()) + + def test_fails_without_permissions(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +jobs: + test: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + # Capture stdout + sys.stdout = StringIO() + check_explicit_permissions([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 1) + self.assertIn("Missing top-level 'permissions:' block", result.errors[0]) + + +class TestSecretsInherit(unittest.TestCase): + def test_passes_without_inherit(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "good.yaml", + """name: Good +jobs: + call-workflow: + uses: ./.github/workflows/other.yaml + secrets: + API_KEY: ${{ secrets.API_KEY }} +""", + ) + result = AuditResult() + + captured = StringIO() + sys.stdout = captured + check_secrets_inherit([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 0) + self.assertIn("No workflows use 'secrets: inherit'", captured.getvalue()) + + def test_fails_with_inherit(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +jobs: + call-workflow: + uses: ./.github/workflows/other.yaml + secrets: inherit +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_secrets_inherit([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 1) + self.assertIn("secrets: inherit", result.errors[0]) + + +class TestForkPRProtection(unittest.TestCase): + def test_passes_with_fork_check(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "good.yaml", + """name: Good +on: + pull_request: +jobs: + test: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - run: echo ${{ secrets.API_KEY }} +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_fork_pr_protection([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + + def test_warns_without_fork_check(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ${{ secrets.API_KEY }} +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_fork_pr_protection([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + self.assertIn("fork PR protection", result.warnings[0]) + + def test_ignores_workflow_without_secrets(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "nosecrets.yaml", + """name: No Secrets +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "hello" +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_fork_pr_protection([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + + +class TestTagAncestryVerification(unittest.TestCase): + def test_passes_with_verification(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "release.yaml", + """name: Release +on: + push: + tags: + - "v*.*.*" +jobs: + verify: + steps: + - run: git merge-base --is-ancestor ${{ github.sha }} origin/main +""", + ) + result = AuditResult() + + captured = StringIO() + sys.stdout = captured + check_tag_ancestry_verification([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + self.assertIn("has ancestry verification", captured.getvalue()) + + def test_passes_with_composite_action(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "release.yaml", + """name: Release +on: + push: + tags: + - "v*.*.*" +jobs: + verify: + steps: + - uses: ./.github/actions/verify-tag-ancestry +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_tag_ancestry_verification([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + + def test_warns_without_verification(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "release.yaml", + """name: Release +on: + push: + tags: + - "v*.*.*" +jobs: + build: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_tag_ancestry_verification([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + self.assertIn("ancestry verification", result.warnings[0]) + + def test_ignores_non_tag_workflows(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "ci.yaml", + """name: CI +on: + push: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_tag_ancestry_verification([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + + +class TestWorkflowDispatchProtection(unittest.TestCase): + def test_passes_without_dispatch(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "release.yaml", + """name: Release +on: + push: + tags: + - "v*" +jobs: + build: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + captured = StringIO() + sys.stdout = captured + check_workflow_dispatch_protection([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + self.assertIn("No workflow_dispatch", captured.getvalue()) + + def test_passes_with_environment_protection(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "publish.yaml", + """name: Publish +on: + workflow_dispatch: +jobs: + deploy: + environment: production + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + captured = StringIO() + sys.stdout = captured + check_workflow_dispatch_protection([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + self.assertIn("with protection", captured.getvalue()) + + def test_warns_without_protection(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "publish.yaml", + """name: Publish +on: + workflow_dispatch: +jobs: + deploy: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_workflow_dispatch_protection([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + self.assertIn("without visible protection", result.warnings[0]) + + def test_ignores_non_sensitive_workflows(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "ci.yaml", + """name: CI +on: + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_workflow_dispatch_protection([workflow], result) + sys.stdout = sys.__stdout__ + + # Should not warn for non-publish/release workflows + self.assertEqual(len(result.warnings), 0) + + +class TestOverlyPermissive(unittest.TestCase): + def test_fails_with_write_all(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +permissions: write-all +jobs: + test: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_overly_permissive([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 1) + self.assertIn("write-all", result.errors[0]) + + def test_warns_contents_write_on_ci(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "ci.yaml", + """name: CI +permissions: + contents: write +jobs: + test: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_overly_permissive([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + self.assertIn("contents: write", result.warnings[0]) + + def test_allows_contents_write_on_release(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "release.yaml", + """name: Release +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_overly_permissive([workflow], result) + sys.stdout = sys.__stdout__ + + # Should not warn for release workflows + self.assertEqual(len(result.warnings), 0) + self.assertEqual(len(result.errors), 0) + + +class TestUnpinnedActions(unittest.TestCase): + def test_warns_on_main_branch(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: some-org/some-action@main +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_unpinned_actions([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + self.assertIn("master/main branch", result.warnings[0]) + + def test_warns_on_master_branch(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: some-org/some-action@master +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_unpinned_actions([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + + def test_passes_with_version_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "good.yaml", + """name: Good +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: some-org/some-action@v1.2.3 +""", + ) + result = AuditResult() + + captured = StringIO() + sys.stdout = captured + check_unpinned_actions([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 0) + self.assertIn("No actions pinned to master/main", captured.getvalue()) + + +class TestDangerousPatterns(unittest.TestCase): + def test_fails_on_untrusted_body_input(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +on: + issue_comment: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "${{ github.event.comment.body }}" +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_dangerous_patterns([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 1) + self.assertIn("untrusted input", result.errors[0]) + + def test_fails_on_pr_title_injection(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "bad.yaml", + """name: Bad +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "${{ github.event.pull_request.title }}" +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_dangerous_patterns([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 1) + + def test_warns_on_pull_request_target(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "risky.yaml", + """name: Risky +on: + pull_request_target: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_dangerous_patterns([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.warnings), 1) + self.assertIn("pull_request_target", result.warnings[0]) + + def test_fails_on_dangerous_pr_target_checkout(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "dangerous.yaml", + """name: Dangerous +on: + pull_request_target: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} +""", + ) + result = AuditResult() + + sys.stdout = StringIO() + check_dangerous_patterns([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 1) + self.assertIn("high risk", result.errors[0]) + + def test_passes_safe_workflow(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow = create_workflow( + Path(tmpdir), + "safe.yaml", + """name: Safe +on: + push: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "Hello" +""", + ) + result = AuditResult() + + captured = StringIO() + sys.stdout = captured + check_dangerous_patterns([workflow], result) + sys.stdout = sys.__stdout__ + + self.assertEqual(len(result.errors), 0) + self.assertEqual(len(result.warnings), 0) + self.assertIn("No obviously dangerous patterns", captured.getvalue()) + + +if __name__ == "__main__": + unittest.main() From c2ce05fc060ee8b8119611fad029123d8c720d00 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:32:58 -0500 Subject: [PATCH 2/5] ci: add justfile targets for security audit - `just audit-gha-security` - Run the security audit - `just test-scripts` - Now also runs audit script tests Co-Authored-By: Claude Opus 4.5 --- justfile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/justfile b/justfile index ec6f8e4a2..7ec535109 100644 --- a/justfile +++ b/justfile @@ -234,13 +234,22 @@ test *args=("-short ./..."): go test {{ args }} -covermode set -coverprofile=cover.out -# Execute Python script tests (e.g., license generation) +# Execute Python script tests (e.g., license generation, security audit) test-scripts: #!/usr/bin/env bash set -eou pipefail {{ _with_debug }} python3 scripts/test_licenses.py + cd scripts && python3 -m unittest test_audit_gha_security -v + +# Audit GitHub Actions workflows for security best practices +audit-gha-security: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + python3 scripts/audit_gha_security.py # Uploads distributions to object storage. If invoked with `env CI=true` then all architectures supported by the Go toolchain are uploaded. upload *args: From a5adc36d4cd2b61362f851f3b469ae960ec73e9f Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:41:03 -0500 Subject: [PATCH 3/5] ci: add GitHub Actions integration to security audit script The script now supports: - `--format json` for programmatic/machine-readable output - `--github-actions` for explicit GHA mode (also auto-detected via GITHUB_ACTIONS env) - GitHub Actions workflow commands (::error::, ::warning::, ::notice::, ::group::) - File and line annotations for errors/warnings - Step summary output (GITHUB_STEP_SUMMARY) - Output variables (GITHUB_OUTPUT): error_count, warning_count, has_errors, has_warnings - `--no-color` flag for CI environments - `--workflows-dir` to specify custom path Tests updated to cover new functionality (34 tests total). Co-Authored-By: Claude Opus 4.5 --- scripts/audit_gha_security.py | 641 ++++++++++++++++++++++------- scripts/test_audit_gha_security.py | 259 ++++++++---- 2 files changed, 680 insertions(+), 220 deletions(-) diff --git a/scripts/audit_gha_security.py b/scripts/audit_gha_security.py index 6c3ea7c84..e51dd8077 100755 --- a/scripts/audit_gha_security.py +++ b/scripts/audit_gha_security.py @@ -3,49 +3,192 @@ GitHub Actions Security Audit Script This script checks for common security best practices in GitHub Actions workflows. -Run from the repository root: python scripts/audit-gha-security.py +Run from the repository root: python scripts/audit_gha_security.py + +Supports both local CLI usage and GitHub Actions integration. + +Usage: + # Local usage with colors + python scripts/audit_gha_security.py + + # GitHub Actions (auto-detected via GITHUB_ACTIONS env var) + # Outputs annotations, step summary, and sets outputs + + # JSON output for programmatic use + python scripts/audit_gha_security.py --format json + + # Explicit GitHub Actions mode + python scripts/audit_gha_security.py --github-actions Based on security hardening applied to posit-dev/publisher """ +import argparse +import json import os import re import sys +from dataclasses import asdict, dataclass, field from pathlib import Path -from dataclasses import dataclass, field +from typing import Optional -# ANSI color codes -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - NC = "\033[0m" # No Color +@dataclass +class Finding: + """A single security finding.""" + + level: str # "error" or "warning" + check: str # Which check found this + file: str # Workflow filename + message: str # Description of the issue + line: Optional[int] = None # Line number if applicable + suggestion: Optional[str] = None # How to fix @dataclass class AuditResult: - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - successes: list[str] = field(default_factory=list) + """Results from the security audit.""" + errors: list[Finding] = field(default_factory=list) + warnings: list[Finding] = field(default_factory=list) + passed: list[str] = field(default_factory=list) -def error(result: AuditResult, message: str) -> None: - result.errors.append(message) - print(f"{Colors.RED}✗ ERROR:{Colors.NC} {message}") + @property + def error_count(self) -> int: + return len(self.errors) + @property + def warning_count(self) -> int: + return len(self.warnings) -def warning(result: AuditResult, message: str) -> None: - result.warnings.append(message) - print(f"{Colors.YELLOW}⚠ WARNING:{Colors.NC} {message}") + @property + def has_errors(self) -> bool: + return self.error_count > 0 + @property + def has_warnings(self) -> bool: + return self.warning_count > 0 -def success(message: str) -> None: - print(f"{Colors.GREEN}✓{Colors.NC} {message}") +class Output: + """Handles output formatting for different environments.""" -def info(message: str) -> None: - print(f" {message}") + def __init__(self, github_actions: bool = False, use_colors: bool = True): + self.github_actions = github_actions + self.use_colors = use_colors and not github_actions + self._summary_lines: list[str] = [] + + # ANSI color codes + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + NC = "\033[0m" + + def _color(self, color: str, text: str) -> str: + if self.use_colors: + return f"{color}{text}{self.NC}" + return text + + def error(self, message: str, file: Optional[str] = None, line: Optional[int] = None) -> None: + if self.github_actions: + location = "" + if file: + location = f" file=.github/workflows/{file}" + if line: + location += f",line={line}" + print(f"::error{location}::{message}") + else: + print(f"{self._color(self.RED, '✗ ERROR:')} {message}") + + def warning(self, message: str, file: Optional[str] = None, line: Optional[int] = None) -> None: + if self.github_actions: + location = "" + if file: + location = f" file=.github/workflows/{file}" + if line: + location += f",line={line}" + print(f"::warning{location}::{message}") + else: + print(f"{self._color(self.YELLOW, '⚠ WARNING:')} {message}") + + def success(self, message: str) -> None: + if self.github_actions: + print(f"::notice::{message}") + else: + print(f"{self._color(self.GREEN, '✓')} {message}") + + def info(self, message: str) -> None: + print(f" {message}") + + def header(self, title: str) -> None: + if self.github_actions: + print(f"::group::{title}") + else: + print(title) + + def end_group(self) -> None: + if self.github_actions: + print("::endgroup::") + else: + print() + + def section(self, title: str) -> None: + print(title) + + def add_summary(self, line: str) -> None: + """Add a line to the GitHub Actions step summary.""" + self._summary_lines.append(line) + + def write_summary(self, result: AuditResult) -> None: + """Write the step summary to GITHUB_STEP_SUMMARY.""" + if not self.github_actions: + return + + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = [ + "## 🔒 GitHub Actions Security Audit", + "", + f"| Result | Count |", + f"|--------|-------|", + f"| ❌ Errors | {result.error_count} |", + f"| ⚠️ Warnings | {result.warning_count} |", + f"| ✅ Passed | {len(result.passed)} |", + "", + ] + + if result.errors: + lines.append("### Errors") + lines.append("") + for finding in result.errors: + lines.append(f"- **{finding.file}**: {finding.message}") + lines.append("") + + if result.warnings: + lines.append("### Warnings") + lines.append("") + for finding in result.warnings: + lines.append(f"- **{finding.file}**: {finding.message}") + lines.append("") + + if not result.errors and not result.warnings: + lines.append("✅ All security checks passed!") + lines.append("") + + with open(summary_file, "a") as f: + f.write("\n".join(lines)) + + def set_output(self, name: str, value: str) -> None: + """Set a GitHub Actions output variable.""" + if not self.github_actions: + return + + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") def get_workflow_files(workflows_dir: Path) -> list[Path]: @@ -56,32 +199,51 @@ def get_workflow_files(workflows_dir: Path) -> list[Path]: return sorted(files) -def check_explicit_permissions(workflows: list[Path], result: AuditResult) -> None: +def find_line_number(content: str, pattern: str) -> Optional[int]: + """Find the line number where a pattern first appears.""" + for i, line in enumerate(content.splitlines(), 1): + if pattern in line: + return i + return None + + +def check_explicit_permissions( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 1: All workflows should have explicit permissions.""" - print("1. Checking for explicit permissions...") - print(" (Workflows should declare minimum required permissions)") - print() + out.header("1. Checking for explicit permissions...") + out.section(" (Workflows should declare minimum required permissions)") for workflow in workflows: content = workflow.read_text() filename = workflow.name - # Check for top-level permissions block if re.search(r"^permissions:", content, re.MULTILINE): - success(f"{filename}: Has explicit permissions") + out.success(f"{filename}: Has explicit permissions") + result.passed.append(f"{filename}: explicit permissions") else: - error(result, f"{filename}: Missing top-level 'permissions:' block") - info(" Add explicit permissions, e.g.:") - info(" permissions:") - info(" contents: read") - print() + finding = Finding( + level="error", + check="explicit-permissions", + file=filename, + message=f"Missing top-level 'permissions:' block", + suggestion="Add explicit permissions, e.g.: permissions: { contents: read }", + ) + result.errors.append(finding) + out.error(finding.message, file=filename) + out.info(" Add explicit permissions, e.g.:") + out.info(" permissions:") + out.info(" contents: read") + + out.end_group() -def check_secrets_inherit(workflows: list[Path], result: AuditResult) -> None: +def check_secrets_inherit( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 2: No 'secrets: inherit' usage.""" - print("2. Checking for 'secrets: inherit' usage...") - print(" (Should pass secrets explicitly to limit exposure)") - print() + out.header("2. Checking for 'secrets: inherit' usage...") + out.section(" (Should pass secrets explicitly to limit exposure)") inherit_found = False for workflow in workflows: @@ -89,23 +251,34 @@ def check_secrets_inherit(workflows: list[Path], result: AuditResult) -> None: filename = workflow.name if "secrets: inherit" in content: - error(result, f"{filename}: Uses 'secrets: inherit' - pass secrets explicitly instead") - # Show line numbers - for i, line in enumerate(content.splitlines(), 1): - if "secrets: inherit" in line: - info(f" Line {i}: {line.strip()}") + line_num = find_line_number(content, "secrets: inherit") + finding = Finding( + level="error", + check="secrets-inherit", + file=filename, + message="Uses 'secrets: inherit' - pass secrets explicitly instead", + line=line_num, + suggestion="Replace 'secrets: inherit' with explicit secret passing", + ) + result.errors.append(finding) + out.error(finding.message, file=filename, line=line_num) inherit_found = True if not inherit_found: - success("No workflows use 'secrets: inherit'") - print() + out.success("No workflows use 'secrets: inherit'") + result.passed.append("No secrets: inherit usage") + + out.end_group() -def check_fork_pr_protection(workflows: list[Path], result: AuditResult) -> None: +def check_fork_pr_protection( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 3: Fork PR protection for jobs using secrets.""" - print("3. Checking for fork PR protection on secret-using jobs...") - print(" (Jobs using secrets should check: github.event.pull_request.head.repo.full_name == github.repository)") - print() + out.header("3. Checking for fork PR protection on secret-using jobs...") + out.section( + " (Jobs using secrets should check: github.event.pull_request.head.repo.full_name == github.repository)" + ) fork_check_pattern = r"github\.event\.pull_request\.head\.repo\.full_name\s*==\s*github\.repository" @@ -113,33 +286,44 @@ def check_fork_pr_protection(workflows: list[Path], result: AuditResult) -> None content = workflow.read_text() filename = workflow.name - # Check if workflow is triggered by pull_request (but not pull_request_target) - if re.search(r"^\s*pull_request\s*:", content, re.MULTILINE) or \ - re.search(r"^\s+- pull_request\s*$", content, re.MULTILINE): - # Check if workflow uses secrets in any form: - # - Inline: ${{ secrets.FOO }} - # - Passed to reusable workflow: secrets: (not empty) + # Check if workflow is triggered by pull_request + if re.search(r"^\s*pull_request\s*:", content, re.MULTILINE) or re.search( + r"^\s+- pull_request\s*$", content, re.MULTILINE + ): + # Check if workflow uses secrets uses_inline_secrets = "${{ secrets." in content - passes_secrets = ( - re.search(r"^\s+secrets:\s*$", content, re.MULTILINE) or - re.search(r"^\s+secrets:\s*\n\s+\w+:", content, re.MULTILINE) - ) + passes_secrets = re.search( + r"^\s+secrets:\s*$", content, re.MULTILINE + ) or re.search(r"^\s+secrets:\s*\n\s+\w+:", content, re.MULTILINE) if uses_inline_secrets or passes_secrets: if re.search(fork_check_pattern, content): - success(f"{filename}: Has fork PR protection") + out.success(f"{filename}: Has fork PR protection") + result.passed.append(f"{filename}: fork PR protection") else: - warning(result, f"{filename}: Triggered by pull_request and passes secrets, but may lack fork PR protection") - info(" Consider adding to jobs that use secrets:") - info(" if: github.event.pull_request.head.repo.full_name == github.repository") - print() - - -def check_tag_ancestry_verification(workflows: list[Path], result: AuditResult) -> None: + finding = Finding( + level="warning", + check="fork-pr-protection", + file=filename, + message="Triggered by pull_request and uses secrets, but may lack fork PR protection", + suggestion="Add condition: if: github.event.pull_request.head.repo.full_name == github.repository", + ) + result.warnings.append(finding) + out.warning(finding.message, file=filename) + out.info(" Consider adding to jobs that use secrets:") + out.info( + " if: github.event.pull_request.head.repo.full_name == github.repository" + ) + + out.end_group() + + +def check_tag_ancestry_verification( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 4: Tag-triggered workflows should verify tag ancestry.""" - print("4. Checking tag-triggered workflows for ancestry verification...") - print(" (Tag-triggered releases should verify tag points to main branch)") - print() + out.header("4. Checking tag-triggered workflows for ancestry verification...") + out.section(" (Tag-triggered releases should verify tag points to main branch)") for workflow in workflows: content = workflow.read_text() @@ -147,24 +331,36 @@ def check_tag_ancestry_verification(workflows: list[Path], result: AuditResult) # Check if workflow is triggered by tag push if "tags:" in content and re.search(r'["\']?v\*', content): - # Check if it verifies tag ancestry has_verification = ( - "merge-base --is-ancestor" in content or - "verify-tag-ancestry" in content + "merge-base --is-ancestor" in content + or "verify-tag-ancestry" in content ) if has_verification: - success(f"{filename}: Tag-triggered workflow has ancestry verification") + out.success(f"{filename}: Tag-triggered workflow has ancestry verification") + result.passed.append(f"{filename}: tag ancestry verification") else: - warning(result, f"{filename}: Tag-triggered workflow may lack ancestry verification") - info(" Consider adding verification that tag points to main branch") - print() + finding = Finding( + level="warning", + check="tag-ancestry", + file=filename, + message="Tag-triggered workflow may lack ancestry verification", + suggestion="Add verification that tag points to a commit on main branch", + ) + result.warnings.append(finding) + out.warning(finding.message, file=filename) + out.info(" Consider adding verification that tag points to main branch") + + out.end_group() -def check_workflow_dispatch_protection(workflows: list[Path], result: AuditResult) -> None: +def check_workflow_dispatch_protection( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 5: Workflow dispatch on sensitive workflows.""" - print("5. Checking for workflow_dispatch on sensitive workflows...") - print(" (Publish/release workflows should not allow manual dispatch without protection)") - print() + out.header("5. Checking for workflow_dispatch on sensitive workflows...") + out.section( + " (Publish/release workflows should not allow manual dispatch without protection)" + ) sensitive_patterns = re.compile(r"publish|release", re.IGNORECASE) @@ -174,54 +370,88 @@ def check_workflow_dispatch_protection(workflows: list[Path], result: AuditResul if sensitive_patterns.search(filename): if "workflow_dispatch" in content: - # Check for protection mechanisms has_protection = ( - "environment:" in content or - "merge-base --is-ancestor" in content + "environment:" in content + or "merge-base --is-ancestor" in content ) if has_protection: - success(f"{filename}: Has workflow_dispatch with protection") + out.success(f"{filename}: Has workflow_dispatch with protection") + result.passed.append(f"{filename}: workflow_dispatch protected") else: - warning(result, f"{filename}: Publish/release workflow has workflow_dispatch without visible protection") - info(" Consider removing workflow_dispatch or adding environment protection") + finding = Finding( + level="warning", + check="workflow-dispatch", + file=filename, + message="Publish/release workflow has workflow_dispatch without visible protection", + suggestion="Remove workflow_dispatch or add environment protection", + ) + result.warnings.append(finding) + out.warning(finding.message, file=filename) + out.info( + " Consider removing workflow_dispatch or adding environment protection" + ) else: - success(f"{filename}: No workflow_dispatch (good for release workflows)") - print() + out.success(f"{filename}: No workflow_dispatch (good for release workflows)") + result.passed.append(f"{filename}: no workflow_dispatch") + + out.end_group() -def check_overly_permissive(workflows: list[Path], result: AuditResult) -> None: +def check_overly_permissive( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 6: Overly permissive permissions.""" - print("6. Checking for overly permissive permissions...") - print() + out.header("6. Checking for overly permissive permissions...") checked_any = False for workflow in workflows: content = workflow.read_text() filename = workflow.name - # Check for write-all if "permissions: write-all" in content: - error(result, f"{filename}: Uses 'permissions: write-all' - too permissive") + line_num = find_line_number(content, "permissions: write-all") + finding = Finding( + level="error", + check="overly-permissive", + file=filename, + message="Uses 'permissions: write-all' - too permissive", + line=line_num, + suggestion="Use specific permissions instead of write-all", + ) + result.errors.append(finding) + out.error(finding.message, file=filename, line=line_num) checked_any = True - # Check for contents: write on non-release workflows if "contents: write" in content: if not re.search(r"release|license|publish", filename, re.IGNORECASE): - warning(result, f"{filename}: Has 'contents: write' - verify this is necessary") + finding = Finding( + level="warning", + check="overly-permissive", + file=filename, + message="Has 'contents: write' - verify this is necessary", + suggestion="Use 'contents: read' unless write access is required", + ) + result.warnings.append(finding) + out.warning(finding.message, file=filename) checked_any = True if not checked_any: - success("No overly permissive permissions found") - print() + out.success("No overly permissive permissions found") + result.passed.append("No overly permissive permissions") + + out.end_group() -def check_unpinned_actions(workflows: list[Path], result: AuditResult) -> None: +def check_unpinned_actions( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 7: Pinned action versions.""" - print("7. Checking for unpinned action versions...") - print(" (Actions should use specific versions, not @master or @main)") - print() + out.header("7. Checking for unpinned action versions...") + out.section(" (Actions should use specific versions, not @master or @main)") - unpinned_pattern = re.compile(r"uses:\s+([^/]+/[^@]+)@(master|main)\s*$", re.MULTILINE) + unpinned_pattern = re.compile( + r"uses:\s+([^/]+/[^@]+)@(master|main)\s*$", re.MULTILINE + ) unpinned_found = False for workflow in workflows: @@ -230,22 +460,33 @@ def check_unpinned_actions(workflows: list[Path], result: AuditResult) -> None: matches = unpinned_pattern.findall(content) if matches: - warning(result, f"{filename}: Uses actions pinned to master/main branch") for action, branch in matches: - info(f" {action}@{branch}") + line_num = find_line_number(content, f"{action}@{branch}") + finding = Finding( + level="warning", + check="unpinned-actions", + file=filename, + message=f"Uses action pinned to {branch} branch: {action}@{branch}", + line=line_num, + suggestion=f"Pin to a specific version or SHA instead of @{branch}", + ) + result.warnings.append(finding) + out.warning(finding.message, file=filename, line=line_num) unpinned_found = True if not unpinned_found: - success("No actions pinned to master/main branches found") - print() + out.success("No actions pinned to master/main branches found") + result.passed.append("All actions properly pinned") + + out.end_group() -def check_dangerous_patterns(workflows: list[Path], result: AuditResult) -> None: +def check_dangerous_patterns( + workflows: list[Path], result: AuditResult, out: Output +) -> None: """CHECK 8: Dangerous patterns.""" - print("8. Checking for dangerous patterns...") - print() + out.header("8. Checking for dangerous patterns...") - # Pattern for untrusted input in expressions untrusted_input_pattern = re.compile( r"\$\{\{\s*github\.event\.(issue|pull_request|comment)\.(body|title)" ) @@ -257,72 +498,196 @@ def check_dangerous_patterns(workflows: list[Path], result: AuditResult) -> None filename = workflow.name # Check for direct use of untrusted input - if untrusted_input_pattern.search(content): - error(result, f"{filename}: Potentially uses untrusted input directly (possible injection)") + match = untrusted_input_pattern.search(content) + if match: + line_num = find_line_number(content, match.group(0)) + finding = Finding( + level="error", + check="dangerous-patterns", + file=filename, + message="Potentially uses untrusted input directly (possible injection)", + line=line_num, + suggestion="Use an intermediate environment variable to sanitize input", + ) + result.errors.append(finding) + out.error(finding.message, file=filename, line=line_num) dangerous_found = True # Check for pull_request_target with checkout of PR code if "pull_request_target" in content: if "actions/checkout" in content: - # Check if it checks out PR head if re.search(r"ref:.*\$\{\{.*pull_request", content): - error(result, f"{filename}: Uses pull_request_target with PR checkout - high risk pattern") + finding = Finding( + level="error", + check="dangerous-patterns", + file=filename, + message="Uses pull_request_target with PR checkout - high risk pattern", + suggestion="Avoid checking out PR code in pull_request_target workflows", + ) + result.errors.append(finding) + out.error(finding.message, file=filename) dangerous_found = True else: - warning(result, f"{filename}: Uses pull_request_target - review carefully") + finding = Finding( + level="warning", + check="dangerous-patterns", + file=filename, + message="Uses pull_request_target - review carefully", + suggestion="Ensure PR code is not executed with elevated permissions", + ) + result.warnings.append(finding) + out.warning(finding.message, file=filename) dangerous_found = True if not dangerous_found: - success("No obviously dangerous patterns found") - print() + out.success("No obviously dangerous patterns found") + result.passed.append("No dangerous patterns") + + out.end_group() + + +def output_json(result: AuditResult) -> None: + """Output results as JSON.""" + output = { + "errors": [asdict(f) for f in result.errors], + "warnings": [asdict(f) for f in result.warnings], + "passed": result.passed, + "summary": { + "error_count": result.error_count, + "warning_count": result.warning_count, + "passed_count": len(result.passed), + "has_errors": result.has_errors, + "has_warnings": result.has_warnings, + }, + } + print(json.dumps(output, indent=2)) def main() -> int: """Run all security checks.""" - workflows_dir = Path(".github/workflows") + parser = argparse.ArgumentParser( + description="Audit GitHub Actions workflows for security best practices" + ) + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format (default: text)", + ) + parser.add_argument( + "--github-actions", + action="store_true", + help="Enable GitHub Actions output mode (annotations, step summary)", + ) + parser.add_argument( + "--no-color", + action="store_true", + help="Disable colored output", + ) + parser.add_argument( + "--workflows-dir", + type=Path, + default=Path(".github/workflows"), + help="Path to workflows directory (default: .github/workflows)", + ) + args = parser.parse_args() - print("=" * 40) - print("GitHub Actions Security Audit") - print("=" * 40) - print() + # Auto-detect GitHub Actions environment + is_github_actions = args.github_actions or os.environ.get("GITHUB_ACTIONS") == "true" + + # Determine if colors should be used + use_colors = not args.no_color and sys.stdout.isatty() and not is_github_actions + + # For JSON output, we'll collect results silently + if args.format == "json": + out = Output(github_actions=False, use_colors=False) + # Redirect stdout to suppress normal output + import io + old_stdout = sys.stdout + sys.stdout = io.StringIO() + else: + out = Output(github_actions=is_github_actions, use_colors=use_colors) + + workflows_dir = args.workflows_dir + + if args.format != "json": + print("=" * 40) + print("GitHub Actions Security Audit") + print("=" * 40) + print() if not workflows_dir.is_dir(): - print(f"{Colors.RED}✗ ERROR:{Colors.NC} No .github/workflows directory found") + if args.format == "json": + sys.stdout = old_stdout + print(json.dumps({"error": "No .github/workflows directory found"})) + else: + out.error("No .github/workflows directory found") return 1 workflows = get_workflow_files(workflows_dir) if not workflows: - print(f"{Colors.RED}✗ ERROR:{Colors.NC} No workflow files found") + if args.format == "json": + sys.stdout = old_stdout + print(json.dumps({"error": "No workflow files found"})) + else: + out.error("No workflow files found") return 1 result = AuditResult() # Run all checks - check_explicit_permissions(workflows, result) - check_secrets_inherit(workflows, result) - check_fork_pr_protection(workflows, result) - check_tag_ancestry_verification(workflows, result) - check_workflow_dispatch_protection(workflows, result) - check_overly_permissive(workflows, result) - check_unpinned_actions(workflows, result) - check_dangerous_patterns(workflows, result) + check_explicit_permissions(workflows, result, out) + check_secrets_inherit(workflows, result, out) + check_fork_pr_protection(workflows, result, out) + check_tag_ancestry_verification(workflows, result, out) + check_workflow_dispatch_protection(workflows, result, out) + check_overly_permissive(workflows, result, out) + check_unpinned_actions(workflows, result, out) + check_dangerous_patterns(workflows, result, out) + + # Handle JSON output + if args.format == "json": + sys.stdout = old_stdout + output_json(result) + return 1 if result.has_errors else 0 # Summary print("=" * 40) print("Audit Summary") print("=" * 40) - print(f"Errors: {Colors.RED}{len(result.errors)}{Colors.NC}") - print(f"Warnings: {Colors.YELLOW}{len(result.warnings)}{Colors.NC}") + if use_colors: + print(f"Errors: {Output.RED}{result.error_count}{Output.NC}") + print(f"Warnings: {Output.YELLOW}{result.warning_count}{Output.NC}") + else: + print(f"Errors: {result.error_count}") + print(f"Warnings: {result.warning_count}") print() - if result.errors: - print(f"{Colors.RED}Security issues found that should be addressed.{Colors.NC}") + # GitHub Actions outputs and summary + if is_github_actions: + out.set_output("error_count", str(result.error_count)) + out.set_output("warning_count", str(result.warning_count)) + out.set_output("has_errors", str(result.has_errors).lower()) + out.set_output("has_warnings", str(result.has_warnings).lower()) + out.write_summary(result) + + if result.has_errors: + if use_colors: + print(f"{Output.RED}Security issues found that should be addressed.{Output.NC}") + else: + print("Security issues found that should be addressed.") return 1 - elif result.warnings: - print(f"{Colors.YELLOW}Some warnings found - review recommended.{Colors.NC}") + elif result.has_warnings: + if use_colors: + print(f"{Output.YELLOW}Some warnings found - review recommended.{Output.NC}") + else: + print("Some warnings found - review recommended.") return 0 else: - print(f"{Colors.GREEN}All checks passed!{Colors.NC}") + if use_colors: + print(f"{Output.GREEN}All checks passed!{Output.NC}") + else: + print("All checks passed!") return 0 diff --git a/scripts/test_audit_gha_security.py b/scripts/test_audit_gha_security.py index a320a41b8..cbfecf4d4 100644 --- a/scripts/test_audit_gha_security.py +++ b/scripts/test_audit_gha_security.py @@ -3,16 +3,19 @@ Tests for the GitHub Actions Security Audit Script. Run with: python scripts/test_audit_gha_security.py +Or: cd scripts && python -m unittest test_audit_gha_security -v """ +import io import sys import tempfile import unittest -from io import StringIO from pathlib import Path from audit_gha_security import ( AuditResult, + Output, + Finding, check_explicit_permissions, check_secrets_inherit, check_fork_pr_protection, @@ -32,6 +35,11 @@ def create_workflow(directory: Path, name: str, content: str) -> Path: return filepath +def create_silent_output() -> Output: + """Create an Output instance that doesn't print to stdout.""" + return Output(github_actions=False, use_colors=False) + + class TestGetWorkflowFiles(unittest.TestCase): def test_finds_yaml_files(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -57,6 +65,49 @@ def test_ignores_non_yaml_files(self): self.assertEqual(files[0].name, "test.yaml") +class TestFinding(unittest.TestCase): + def test_finding_creation(self): + finding = Finding( + level="error", + check="test-check", + file="test.yaml", + message="Test message", + line=10, + suggestion="Fix it", + ) + self.assertEqual(finding.level, "error") + self.assertEqual(finding.check, "test-check") + self.assertEqual(finding.file, "test.yaml") + self.assertEqual(finding.message, "Test message") + self.assertEqual(finding.line, 10) + self.assertEqual(finding.suggestion, "Fix it") + + +class TestAuditResult(unittest.TestCase): + def test_empty_result(self): + result = AuditResult() + self.assertEqual(result.error_count, 0) + self.assertEqual(result.warning_count, 0) + self.assertFalse(result.has_errors) + self.assertFalse(result.has_warnings) + + def test_with_errors(self): + result = AuditResult() + result.errors.append( + Finding(level="error", check="test", file="test.yaml", message="error") + ) + self.assertEqual(result.error_count, 1) + self.assertTrue(result.has_errors) + + def test_with_warnings(self): + result = AuditResult() + result.warnings.append( + Finding(level="warning", check="test", file="test.yaml", message="warning") + ) + self.assertEqual(result.warning_count, 1) + self.assertTrue(result.has_warnings) + + class TestExplicitPermissions(unittest.TestCase): def test_passes_with_permissions(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -72,15 +123,15 @@ def test_passes_with_permissions(self): """, ) result = AuditResult() + out = create_silent_output() - # Capture stdout - captured = StringIO() - sys.stdout = captured - check_explicit_permissions([workflow], result) + sys.stdout = io.StringIO() + check_explicit_permissions([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 0) - self.assertIn("Has explicit permissions", captured.getvalue()) + self.assertIn("Has explicit permissions", output) def test_fails_without_permissions(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -94,14 +145,14 @@ def test_fails_without_permissions(self): """, ) result = AuditResult() + out = create_silent_output() - # Capture stdout - sys.stdout = StringIO() - check_explicit_permissions([workflow], result) + sys.stdout = io.StringIO() + check_explicit_permissions([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 1) - self.assertIn("Missing top-level 'permissions:' block", result.errors[0]) + self.assertIn("permissions", result.errors[0].message) class TestSecretsInherit(unittest.TestCase): @@ -119,14 +170,15 @@ def test_passes_without_inherit(self): """, ) result = AuditResult() + out = create_silent_output() - captured = StringIO() - sys.stdout = captured - check_secrets_inherit([workflow], result) + sys.stdout = io.StringIO() + check_secrets_inherit([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 0) - self.assertIn("No workflows use 'secrets: inherit'", captured.getvalue()) + self.assertIn("No workflows use 'secrets: inherit'", output) def test_fails_with_inherit(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -141,13 +193,14 @@ def test_fails_with_inherit(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_secrets_inherit([workflow], result) + sys.stdout = io.StringIO() + check_secrets_inherit([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 1) - self.assertIn("secrets: inherit", result.errors[0]) + self.assertIn("secrets: inherit", result.errors[0].message) class TestForkPRProtection(unittest.TestCase): @@ -168,9 +221,10 @@ def test_passes_with_fork_check(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_fork_pr_protection([workflow], result) + sys.stdout = io.StringIO() + check_fork_pr_protection([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) @@ -191,13 +245,14 @@ def test_warns_without_fork_check(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_fork_pr_protection([workflow], result) + sys.stdout = io.StringIO() + check_fork_pr_protection([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) - self.assertIn("fork PR protection", result.warnings[0]) + self.assertIn("fork PR protection", result.warnings[0].message) def test_ignores_workflow_without_secrets(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -215,9 +270,10 @@ def test_ignores_workflow_without_secrets(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_fork_pr_protection([workflow], result) + sys.stdout = io.StringIO() + check_fork_pr_protection([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) @@ -241,14 +297,15 @@ def test_passes_with_verification(self): """, ) result = AuditResult() + out = create_silent_output() - captured = StringIO() - sys.stdout = captured - check_tag_ancestry_verification([workflow], result) + sys.stdout = io.StringIO() + check_tag_ancestry_verification([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) - self.assertIn("has ancestry verification", captured.getvalue()) + self.assertIn("has ancestry verification", output) def test_passes_with_composite_action(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -267,9 +324,10 @@ def test_passes_with_composite_action(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_tag_ancestry_verification([workflow], result) + sys.stdout = io.StringIO() + check_tag_ancestry_verification([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) @@ -290,13 +348,14 @@ def test_warns_without_verification(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_tag_ancestry_verification([workflow], result) + sys.stdout = io.StringIO() + check_tag_ancestry_verification([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) - self.assertIn("ancestry verification", result.warnings[0]) + self.assertIn("ancestry verification", result.warnings[0].message) def test_ignores_non_tag_workflows(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -314,9 +373,10 @@ def test_ignores_non_tag_workflows(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_tag_ancestry_verification([workflow], result) + sys.stdout = io.StringIO() + check_tag_ancestry_verification([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) @@ -339,14 +399,15 @@ def test_passes_without_dispatch(self): """, ) result = AuditResult() + out = create_silent_output() - captured = StringIO() - sys.stdout = captured - check_workflow_dispatch_protection([workflow], result) + sys.stdout = io.StringIO() + check_workflow_dispatch_protection([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) - self.assertIn("No workflow_dispatch", captured.getvalue()) + self.assertIn("No workflow_dispatch", output) def test_passes_with_environment_protection(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -363,14 +424,15 @@ def test_passes_with_environment_protection(self): """, ) result = AuditResult() + out = create_silent_output() - captured = StringIO() - sys.stdout = captured - check_workflow_dispatch_protection([workflow], result) + sys.stdout = io.StringIO() + check_workflow_dispatch_protection([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) - self.assertIn("with protection", captured.getvalue()) + self.assertIn("with protection", output) def test_warns_without_protection(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -386,13 +448,14 @@ def test_warns_without_protection(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_workflow_dispatch_protection([workflow], result) + sys.stdout = io.StringIO() + check_workflow_dispatch_protection([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) - self.assertIn("without visible protection", result.warnings[0]) + self.assertIn("without visible protection", result.warnings[0].message) def test_ignores_non_sensitive_workflows(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -408,12 +471,12 @@ def test_ignores_non_sensitive_workflows(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_workflow_dispatch_protection([workflow], result) + sys.stdout = io.StringIO() + check_workflow_dispatch_protection([workflow], result, out) sys.stdout = sys.__stdout__ - # Should not warn for non-publish/release workflows self.assertEqual(len(result.warnings), 0) @@ -431,13 +494,14 @@ def test_fails_with_write_all(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_overly_permissive([workflow], result) + sys.stdout = io.StringIO() + check_overly_permissive([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 1) - self.assertIn("write-all", result.errors[0]) + self.assertIn("write-all", result.errors[0].message) def test_warns_contents_write_on_ci(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -453,13 +517,14 @@ def test_warns_contents_write_on_ci(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_overly_permissive([workflow], result) + sys.stdout = io.StringIO() + check_overly_permissive([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) - self.assertIn("contents: write", result.warnings[0]) + self.assertIn("contents: write", result.warnings[0].message) def test_allows_contents_write_on_release(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -475,12 +540,12 @@ def test_allows_contents_write_on_release(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_overly_permissive([workflow], result) + sys.stdout = io.StringIO() + check_overly_permissive([workflow], result, out) sys.stdout = sys.__stdout__ - # Should not warn for release workflows self.assertEqual(len(result.warnings), 0) self.assertEqual(len(result.errors), 0) @@ -500,13 +565,14 @@ def test_warns_on_main_branch(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_unpinned_actions([workflow], result) + sys.stdout = io.StringIO() + check_unpinned_actions([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) - self.assertIn("master/main branch", result.warnings[0]) + self.assertIn("main", result.warnings[0].message) def test_warns_on_master_branch(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -522,9 +588,10 @@ def test_warns_on_master_branch(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_unpinned_actions([workflow], result) + sys.stdout = io.StringIO() + check_unpinned_actions([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) @@ -544,14 +611,15 @@ def test_passes_with_version_tag(self): """, ) result = AuditResult() + out = create_silent_output() - captured = StringIO() - sys.stdout = captured - check_unpinned_actions([workflow], result) + sys.stdout = io.StringIO() + check_unpinned_actions([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 0) - self.assertIn("No actions pinned to master/main", captured.getvalue()) + self.assertIn("No actions pinned to master/main", output) class TestDangerousPatterns(unittest.TestCase): @@ -571,13 +639,14 @@ def test_fails_on_untrusted_body_input(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_dangerous_patterns([workflow], result) + sys.stdout = io.StringIO() + check_dangerous_patterns([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 1) - self.assertIn("untrusted input", result.errors[0]) + self.assertIn("untrusted input", result.errors[0].message) def test_fails_on_pr_title_injection(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -595,9 +664,10 @@ def test_fails_on_pr_title_injection(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_dangerous_patterns([workflow], result) + sys.stdout = io.StringIO() + check_dangerous_patterns([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 1) @@ -618,13 +688,14 @@ def test_warns_on_pull_request_target(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_dangerous_patterns([workflow], result) + sys.stdout = io.StringIO() + check_dangerous_patterns([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.warnings), 1) - self.assertIn("pull_request_target", result.warnings[0]) + self.assertIn("pull_request_target", result.warnings[0].message) def test_fails_on_dangerous_pr_target_checkout(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -644,13 +715,14 @@ def test_fails_on_dangerous_pr_target_checkout(self): """, ) result = AuditResult() + out = create_silent_output() - sys.stdout = StringIO() - check_dangerous_patterns([workflow], result) + sys.stdout = io.StringIO() + check_dangerous_patterns([workflow], result, out) sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 1) - self.assertIn("high risk", result.errors[0]) + self.assertIn("high risk", result.errors[0].message) def test_passes_safe_workflow(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -669,15 +741,38 @@ def test_passes_safe_workflow(self): """, ) result = AuditResult() + out = create_silent_output() - captured = StringIO() - sys.stdout = captured - check_dangerous_patterns([workflow], result) + sys.stdout = io.StringIO() + check_dangerous_patterns([workflow], result, out) + output = sys.stdout.getvalue() sys.stdout = sys.__stdout__ self.assertEqual(len(result.errors), 0) self.assertEqual(len(result.warnings), 0) - self.assertIn("No obviously dangerous patterns", captured.getvalue()) + self.assertIn("No obviously dangerous patterns", output) + + +class TestGitHubActionsOutput(unittest.TestCase): + def test_error_annotation_format(self): + out = Output(github_actions=True, use_colors=False) + + sys.stdout = io.StringIO() + out.error("Test error", file="test.yaml", line=10) + output = sys.stdout.getvalue() + sys.stdout = sys.__stdout__ + + self.assertIn("::error file=.github/workflows/test.yaml,line=10::Test error", output) + + def test_warning_annotation_format(self): + out = Output(github_actions=True, use_colors=False) + + sys.stdout = io.StringIO() + out.warning("Test warning", file="test.yaml") + output = sys.stdout.getvalue() + sys.stdout = sys.__stdout__ + + self.assertIn("::warning file=.github/workflows/test.yaml::Test warning", output) if __name__ == "__main__": From 6586ab0462068854cf4116e0d2dc37391b6aaef7 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:42:22 -0500 Subject: [PATCH 4/5] ci: add workflow to run security audit Adds a manual workflow_dispatch trigger to run the GHA security audit script on demand. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/security-audit.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/security-audit.yaml diff --git a/.github/workflows/security-audit.yaml b/.github/workflows/security-audit.yaml new file mode 100644 index 000000000..e9f556167 --- /dev/null +++ b/.github/workflows/security-audit.yaml @@ -0,0 +1,19 @@ +name: Security Audit +on: + workflow_dispatch: +permissions: + contents: read +jobs: + audit: + name: Audit GitHub Actions Security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run security audit + run: python scripts/audit_gha_security.py From 016ac6cdb383c9656545dd45bfef5ab8978a2965 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:51:22 -0500 Subject: [PATCH 5/5] Clean up comments in audit_gha_security.py Removed unnecessary comment about security hardening. --- scripts/audit_gha_security.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/audit_gha_security.py b/scripts/audit_gha_security.py index e51dd8077..595bb05b2 100755 --- a/scripts/audit_gha_security.py +++ b/scripts/audit_gha_security.py @@ -19,8 +19,6 @@ # Explicit GitHub Actions mode python scripts/audit_gha_security.py --github-actions - -Based on security hardening applied to posit-dev/publisher """ import argparse