Securing GitHub Actions Workflows When to Use - When GitHub Actions is the CI/CD platform and workflows need hardening against supply chain attacks - When workflows handle secrets, deploy to production, or have elevated permissions - When preventing script injection via untrusted PR titles, branch names, or commit messages - When requiring audit trails and approval gates for workflow modifications - When third-party actions pose supply chain risk through mutable version tags Do not use for securing other CI/CD platforms (see platform-specific hardening guides), for application vulnerability s…

, ref):\n unpinned.append({\n \"action\": action,\n \"ref\": ref,\n \"line\": line_num,\n \"file\": str(workflow_path),\n })\n self.findings.append({\n \"severity\": \"medium\",\n \"type\": \"Unpinned Action\",\n \"detail\": f\"{action}@{ref} at line {line_num}\",\n \"file\": str(workflow_path),\n })\n return unpinned\n\n def check_permissions(self, workflow_path, content):\n \"\"\"Check for overly permissive GITHUB_TOKEN permissions.\"\"\"\n issues = []\n if not isinstance(content, dict) or \"raw\" in content:\n return issues\n\n top_perms = content.get(\"permissions\")\n if top_perms is None:\n issues.append({\n \"issue\": \"No top-level permissions defined (inherits defaults)\",\n \"file\": str(workflow_path),\n })\n self.findings.append({\n \"severity\": \"medium\",\n \"type\": \"Missing Permissions\",\n \"detail\": \"Workflow has no permissions block\",\n \"file\": str(workflow_path),\n })\n\n if top_perms == \"write-all\" or (isinstance(top_perms, dict) and\n top_perms.get(\"contents\") == \"write\" and\n top_perms.get(\"actions\") == \"write\"):\n issues.append({\"issue\": \"Overly permissive write-all\", \"file\": str(workflow_path)})\n self.findings.append({\n \"severity\": \"high\",\n \"type\": \"Excessive Permissions\",\n \"detail\": \"write-all permissions granted\",\n \"file\": str(workflow_path),\n })\n\n return issues\n\n def check_script_injection(self, workflow_path, content):\n \"\"\"Check for user-controlled input in run steps (script injection).\"\"\"\n injections = []\n raw = content.get(\"raw\", \"\") if \"raw\" in content else \"\"\n if not raw:\n try:\n raw = Path(workflow_path).read_text()\n except Exception:\n return injections\n\n dangerous_contexts = [\n \"github.event.pull_request.title\",\n \"github.event.pull_request.body\",\n \"github.event.issue.title\",\n \"github.event.issue.body\",\n \"github.event.comment.body\",\n \"github.event.review.body\",\n \"github.head_ref\",\n ]\n\n in_run = False\n for line_num, line in enumerate(raw.splitlines(), 1):\n stripped = line.strip()\n if stripped.startswith(\"run:\") or stripped.startswith(\"run: |\"):\n in_run = True\n elif in_run and not stripped.startswith(\"-\") and not stripped.startswith(\"#\"):\n for ctx in dangerous_contexts:\n if f\"${{{{ {ctx}\" in line or f\"${{{{{ctx}\" in line:\n injections.append({\n \"context\": ctx,\n \"line\": line_num,\n \"file\": str(workflow_path),\n })\n self.findings.append({\n \"severity\": \"high\",\n \"type\": \"Script Injection\",\n \"detail\": f\"{ctx} in run step at line {line_num}\",\n \"file\": str(workflow_path),\n })\n if stripped and not stripped.startswith(\"-\") and not stripped.startswith(\"#\") and \":\" in stripped and not stripped.startswith(\"run\"):\n in_run = False\n\n return injections\n\n def check_dangerous_triggers(self, workflow_path, content):\n \"\"\"Check for dangerous event triggers.\"\"\"\n issues = []\n if not isinstance(content, dict) or \"raw\" in content:\n raw = content.get(\"raw\", \"\")\n if \"pull_request_target\" in raw:\n issues.append({\"trigger\": \"pull_request_target\", \"file\": str(workflow_path)})\n self.findings.append({\n \"severity\": \"high\",\n \"type\": \"Dangerous Trigger\",\n \"detail\": \"pull_request_target allows fork code to run with base permissions\",\n \"file\": str(workflow_path),\n })\n return issues\n\n on_block = content.get(\"on\", content.get(True, {}))\n if isinstance(on_block, dict) and \"pull_request_target\" in on_block:\n issues.append({\"trigger\": \"pull_request_target\", \"file\": str(workflow_path)})\n self.findings.append({\n \"severity\": \"high\",\n \"type\": \"Dangerous Trigger\",\n \"detail\": \"pull_request_target trigger used\",\n \"file\": str(workflow_path),\n })\n return issues\n\n def audit_all(self):\n \"\"\"Run all security checks on all workflow files.\"\"\"\n workflows = self.find_workflows()\n results = []\n for wf in workflows:\n content = self._load_workflow(wf)\n unpinned = self.check_sha_pinning(wf, content)\n perms = self.check_permissions(wf, content)\n injections = self.check_script_injection(wf, content)\n triggers = self.check_dangerous_triggers(wf, content)\n results.append({\n \"workflow\": str(wf),\n \"unpinned_actions\": len(unpinned),\n \"permission_issues\": len(perms),\n \"script_injections\": len(injections),\n \"dangerous_triggers\": len(triggers),\n })\n return results\n\n def generate_report(self):\n audit = self.audit_all()\n report = {\n \"report_date\": datetime.utcnow().isoformat(),\n \"repository\": str(self.repo_path),\n \"workflows_scanned\": len(audit),\n \"audit_summary\": audit,\n \"findings\": self.findings,\n \"total_findings\": len(self.findings),\n }\n out = self.output_dir / \"gha_security_report.json\"\n with open(out, \"w\") as f:\n json.dump(report, f, indent=2)\n print(json.dumps(report, indent=2))\n return report\n\n\ndef main():\n repo = sys.argv[1] if len(sys.argv) > 1 else \".\"\n agent = GitHubActionsSecurityAgent(repo)\n agent.generate_report()\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8248,"content_sha256":"530b2e57d5d584b6dcfb84165dddfe9c454599dd58cd1253a3b57c1b6d6f6390"},{"filename":"scripts/process.py","content":"#!/usr/bin/env python3\n\"\"\"\nGitHub Actions Workflow Security Audit Script\n\nAnalyzes workflow files for security issues including unpinned actions,\nexcessive permissions, script injection risks, and insecure patterns.\n\nUsage:\n python process.py --workflows-dir .github/workflows/ --output audit-report.json\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nimport yaml\n\n\n@dataclass\nclass SecurityFinding:\n file: str\n line: int\n check: str\n severity: str\n message: str\n remediation: str\n\n\nSHA_PATTERN = re.compile(r\"@[0-9a-f]{40}\")\nTAG_PATTERN = re.compile(r\"@v?\\d+(\\.\\d+)*$\")\nINJECTION_PATTERN = re.compile(r\"\\$\\{\\{\\s*github\\.event\\.(issue|pull_request|comment|review)\\.\\w+\")\nDANGEROUS_CONTEXTS = [\n \"github.event.issue.title\",\n \"github.event.issue.body\",\n \"github.event.pull_request.title\",\n \"github.event.pull_request.body\",\n \"github.event.comment.body\",\n \"github.event.review.body\",\n \"github.head_ref\",\n]\n\n\ndef load_workflow(filepath: str) -> dict:\n \"\"\"Load a GitHub Actions workflow YAML file.\"\"\"\n try:\n with open(filepath, \"r\") as f:\n return yaml.safe_load(f) or {}\n except (yaml.YAMLError, FileNotFoundError):\n return {}\n\n\ndef check_action_pinning(workflow: dict, filepath: str) -> list:\n \"\"\"Check if actions are pinned to SHA digests.\"\"\"\n findings = []\n filename = os.path.basename(filepath)\n\n for job_name, job in workflow.get(\"jobs\", {}).items():\n for i, step in enumerate(job.get(\"steps\", [])):\n uses = step.get(\"uses\", \"\")\n if not uses or uses.startswith(\"./\"):\n continue\n if not SHA_PATTERN.search(uses):\n findings.append(SecurityFinding(\n file=filename, line=0,\n check=\"ACTION_PINNING\",\n severity=\"HIGH\",\n message=f\"Job '{job_name}' step {i}: '{uses}' not pinned to SHA digest\",\n remediation=f\"Pin to SHA: {uses.split('@')[0]}@\u003ccommit-sha>\"\n ))\n return findings\n\n\ndef check_permissions(workflow: dict, filepath: str) -> list:\n \"\"\"Check for overly permissive GITHUB_TOKEN permissions.\"\"\"\n findings = []\n filename = os.path.basename(filepath)\n\n top_perms = workflow.get(\"permissions\")\n if top_perms is None:\n findings.append(SecurityFinding(\n file=filename, line=0,\n check=\"PERMISSIONS\",\n severity=\"MEDIUM\",\n message=\"No top-level permissions defined. Inherits default (may be write-all).\",\n remediation=\"Add 'permissions: {}' at workflow level and grant per-job.\"\n ))\n elif top_perms == \"write-all\" or (isinstance(top_perms, dict) and\n all(v == \"write\" for v in top_perms.values())):\n findings.append(SecurityFinding(\n file=filename, line=0,\n check=\"PERMISSIONS\",\n severity=\"HIGH\",\n message=\"Workflow has write-all permissions.\",\n remediation=\"Restrict to minimum required permissions per job.\"\n ))\n return findings\n\n\ndef check_script_injection(workflow: dict, filepath: str) -> list:\n \"\"\"Check for script injection via user-controlled inputs.\"\"\"\n findings = []\n filename = os.path.basename(filepath)\n\n for job_name, job in workflow.get(\"jobs\", {}).items():\n for i, step in enumerate(job.get(\"steps\", [])):\n run_cmd = step.get(\"run\", \"\")\n if not run_cmd:\n continue\n for ctx in DANGEROUS_CONTEXTS:\n if f\"${{{{ {ctx}\" in run_cmd or f\"${{{{{ctx}\" in run_cmd:\n findings.append(SecurityFinding(\n file=filename, line=0,\n check=\"SCRIPT_INJECTION\",\n severity=\"CRITICAL\",\n message=f\"Job '{job_name}' step {i}: '{ctx}' interpolated in run step\",\n remediation=\"Use env variable: env: VAR: ${{ \" + ctx + \" }} then ${VAR}\"\n ))\n return findings\n\n\ndef check_pr_target(workflow: dict, filepath: str) -> list:\n \"\"\"Check for dangerous pull_request_target usage.\"\"\"\n findings = []\n filename = os.path.basename(filepath)\n\n triggers = workflow.get(\"on\", {})\n if isinstance(triggers, dict) and \"pull_request_target\" in triggers:\n for job_name, job in workflow.get(\"jobs\", {}).items():\n for step in job.get(\"steps\", []):\n uses = step.get(\"uses\", \"\")\n if \"checkout\" in uses:\n with_ref = step.get(\"with\", {}).get(\"ref\", \"\")\n if \"pull_request\" in with_ref or \"head\" in with_ref:\n findings.append(SecurityFinding(\n file=filename, line=0,\n check=\"PR_TARGET_CHECKOUT\",\n severity=\"CRITICAL\",\n message=f\"Job '{job_name}': pull_request_target with PR code checkout\",\n remediation=\"Never checkout PR code in pull_request_target workflows.\"\n ))\n return findings\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"GitHub Actions Security Audit\")\n parser.add_argument(\"--workflows-dir\", required=True)\n parser.add_argument(\"--output\", default=\"actions-security-report.json\")\n parser.add_argument(\"--fail-on-findings\", action=\"store_true\")\n args = parser.parse_args()\n\n workflows_dir = os.path.abspath(args.workflows_dir)\n all_findings = []\n\n workflow_files = list(Path(workflows_dir).glob(\"*.yml\")) + list(Path(workflows_dir).glob(\"*.yaml\"))\n print(f\"[*] Auditing {len(workflow_files)} workflow files in {workflows_dir}\")\n\n for wf_path in workflow_files:\n workflow = load_workflow(str(wf_path))\n if not workflow:\n continue\n\n all_findings.extend(check_action_pinning(workflow, str(wf_path)))\n all_findings.extend(check_permissions(workflow, str(wf_path)))\n all_findings.extend(check_script_injection(workflow, str(wf_path)))\n all_findings.extend(check_pr_target(workflow, str(wf_path)))\n\n severity_counts = {}\n for f in all_findings:\n severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1\n\n report = {\n \"metadata\": {\n \"directory\": workflows_dir,\n \"date\": datetime.now(timezone.utc).isoformat(),\n \"workflows_scanned\": len(workflow_files)\n },\n \"summary\": {\n \"total_findings\": len(all_findings),\n \"severity_counts\": severity_counts\n },\n \"findings\": [\n {\"file\": f.file, \"check\": f.check, \"severity\": f.severity,\n \"message\": f.message, \"remediation\": f.remediation}\n for f in sorted(all_findings,\n key=lambda x: {\"CRITICAL\": 0, \"HIGH\": 1, \"MEDIUM\": 2, \"LOW\": 3}.get(x.severity, 4))\n ]\n }\n\n output_path = os.path.abspath(args.output)\n with open(output_path, \"w\") as f:\n json.dump(report, f, indent=2)\n print(f\"[*] Report: {output_path}\")\n\n for f in all_findings:\n print(f\" [{f.severity}] {f.file}: {f.message}\")\n\n passed = len(all_findings) == 0\n print(f\"\\n[{'PASS' if passed else 'FAIL'}] {len(all_findings)} security findings\")\n\n if args.fail_on_findings and not passed:\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7533,"content_sha256":"2b3cac28679287eea1957c1d9d1c000f42cacf351164b8afcb3bba3c22e53927"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Securing GitHub Actions Workflows","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to Use","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When GitHub Actions is the CI/CD platform and workflows need hardening against supply chain attacks","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When workflows handle secrets, deploy to production, or have elevated permissions","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When preventing script injection via untrusted PR titles, branch names, or commit messages","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When requiring audit trails and approval gates for workflow modifications","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When third-party actions pose supply chain risk through mutable version tags","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Do not use","type":"text","marks":[{"type":"strong"}]},{"text":" for securing other CI/CD platforms (see platform-specific hardening guides), for application vulnerability scanning (use SAST/DAST), or for secret detection in code (use Gitleaks).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitHub repository with GitHub Actions enabled","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitHub organization admin access for organization-level settings","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Understanding of GitHub Actions workflow syntax and events","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: Pin Actions to SHA Digests","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# INSECURE: Mutable tag can be overwritten by attacker\n- uses: actions/checkout@v4\n\n# SECURE: Pinned to immutable SHA digest\n- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1\n\n# Use Dependabot to auto-update pinned SHAs\n# .github/dependabot.yml\nversion: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n commit-message:\n prefix: \"ci\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: Minimize GITHUB_TOKEN Permissions","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# Set restrictive default permissions at workflow level\nname: CI Pipeline\npermissions: {} # Start with no permissions\n\non: [push, pull_request]\n\njobs:\n build:\n runs-on: ubuntu-latest\n permissions:\n contents: read # Only what's needed\n steps:\n - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11\n\n deploy:\n runs-on: ubuntu-latest\n needs: build\n if: github.ref == 'refs/heads/main'\n permissions:\n contents: read\n deployments: write\n id-token: write # For OIDC-based cloud auth\n steps:\n - name: Deploy\n run: echo \"deploying\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: Prevent Script Injection","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# VULNERABLE: User-controlled input in run step\n- run: echo \"PR title is ${{ github.event.pull_request.title }}\"\n\n# SECURE: Use environment variable (properly escaped by shell)\n- name: Process PR\n env:\n PR_TITLE: ${{ github.event.pull_request.title }}\n PR_BODY: ${{ github.event.pull_request.body }}\n run: |\n echo \"PR title is ${PR_TITLE}\"\n echo \"PR body is ${PR_BODY}\"\n\n# SECURE: Use actions/github-script for complex operations\n- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea\n with:\n script: |\n const title = context.payload.pull_request.title;\n console.log(`PR title: ${title}`);","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4: Secure Fork Pull Request Handling","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# DANGEROUS: pull_request_target runs with base repo permissions\n# on: pull_request_target # AVOID unless absolutely necessary\n\n# SAFE: pull_request runs in fork context with limited permissions\non:\n pull_request:\n branches: [main]\n\n# If pull_request_target is required, never checkout PR code:\non:\n pull_request_target:\n types: [labeled]\n\njobs:\n safe-job:\n if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')\n runs-on: ubuntu-latest\n permissions:\n contents: read\n steps:\n # NEVER do: actions/checkout with ref: ${{ github.event.pull_request.head.sha }}\n - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11\n # This checks out the BASE branch, not the PR","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5: Protect Secrets and Environment Variables","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"jobs:\n deploy:\n runs-on: ubuntu-latest\n environment: production # Requires approval\n steps:\n - name: Deploy with secret\n env:\n # Secrets are masked in logs automatically\n DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}\n run: |\n # Never echo secrets\n # echo \"$DEPLOY_KEY\" # BAD\n deploy-tool --key-file \u003c(echo \"$DEPLOY_KEY\")\n\n - name: Audit secret access\n run: |\n # Log that secret was used without exposing it\n echo \"::notice::Deploy key accessed for production deployment\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 6: Implement Workflow Change Controls","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"yaml"},"content":[{"text":"# Require CODEOWNERS approval for workflow changes\n# .github/CODEOWNERS\n.github/workflows/ @security-team @platform-team\n.github/actions/ @security-team @platform-team\n\n# Organization settings:\n# 1. Settings > Actions > General > Fork PR policies\n# - Require approval for first-time contributors\n# - Require approval for all outside collaborators\n# 2. Settings > Actions > General > Workflow permissions\n# - Read repository contents and packages permissions\n# - Do NOT allow GitHub Actions to create and approve PRs","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Key Concepts","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Term","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Definition","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SHA Pinning","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Referencing GitHub Actions by their immutable commit SHA instead of mutable version tags","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Script Injection","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Attack where untrusted input (PR title, branch name) is interpolated into shell commands","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GITHUB_TOKEN","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Automatically generated token with configurable permissions scoped to the current repository","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pull_request_target","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dangerous event trigger that runs in the base repo context with full permissions on fork PRs","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Environment Protection","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GitHub feature requiring manual approval before jobs accessing an environment can run","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CODEOWNERS","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"File defining required reviewers for specific paths including workflow files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"OIDC Federation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using GitHub's OIDC token to authenticate to cloud providers without storing long-lived credentials","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Tools & Systems","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dependabot","type":"text","marks":[{"type":"strong"}]},{"text":": Automated dependency updater that keeps pinned action SHAs current","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"StepSecurity Harden Runner","type":"text","marks":[{"type":"strong"}]},{"text":": GitHub Action that monitors and restricts outbound network calls from workflows","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"actionlint","type":"text","marks":[{"type":"strong"}]},{"text":": Linter for GitHub Actions workflow files that detects security issues","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"allstar","type":"text","marks":[{"type":"strong"}]},{"text":": GitHub App by OpenSSF that enforces security policies on repositories","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scorecard","type":"text","marks":[{"type":"strong"}]},{"text":": OpenSSF tool that evaluates supply chain security practices including CI/CD","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Common Scenarios","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Scenario: Preventing Supply Chain Attack via Compromised Third-Party Action","type":"text"}]},{"type":"paragraph","content":[{"text":"Context","type":"text","marks":[{"type":"strong"}]},{"text":": A widely-used GitHub Action is compromised and its v3 tag is updated to include credential-stealing code. Repositories using ","type":"text"},{"text":"@v3","type":"text","marks":[{"type":"code_inline"}]},{"text":" automatically pull the malicious version.","type":"text"}]},{"type":"paragraph","content":[{"text":"Approach","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pin all actions to SHA digests immediately across all repositories","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Configure Dependabot for github-actions ecosystem to manage SHA updates","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Restrict GITHUB_TOKEN permissions so even compromised actions have minimal access","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Add StepSecurity harden-runner to detect anomalous outbound network calls","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Review all third-party actions and replace unnecessary ones with inline scripts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Require CODEOWNERS approval for any changes to .github/workflows/","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Pitfalls","type":"text","marks":[{"type":"strong"}]},{"text":": SHA pinning without Dependabot means missing legitimate security updates to actions. Overly restrictive permissions can break legitimate workflows. Using ","type":"text"},{"text":"pull_request_target","type":"text","marks":[{"type":"code_inline"}]},{"text":" for label-based gating still exposes secrets if the workflow checks out PR code.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Format","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"GitHub Actions Security Audit\n================================\nRepository: org/web-application\nDate: 2026-02-23\n\nWORKFLOW ANALYSIS:\n Total workflows: 8\n Total action references: 34\n\nSHA PINNING:\n [FAIL] 12/34 actions use mutable tags instead of SHA digests\n - .github/workflows/ci.yml: actions/setup-node@v4\n - .github/workflows/deploy.yml: aws-actions/configure-aws-credentials@v4\n\nPERMISSIONS:\n [FAIL] 3/8 workflows have no explicit permissions (inherit default)\n [WARN] 1/8 workflows request write-all permissions\n\nSCRIPT INJECTION:\n [FAIL] 2 workflow steps interpolate user input directly\n - .github/workflows/pr-check.yml:23: ${{ github.event.pull_request.title }}\n\nSECRETS:\n [PASS] No secrets exposed in workflow logs\n [PASS] All production deployments use environment protection\n\nSCORE: 6/10 (Remediate 5 HIGH findings)","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"securing-github-actions-workflows","tags":["devsecops","cicd","github-actions","supply-chain","workflow-security","secure-sdlc"],"author":"@skillopedia","domain":"cybersecurity","source":{"stars":13207,"repo_name":"anthropic-cybersecurity-skills","origin_url":"https://github.com/mukul975/anthropic-cybersecurity-skills/blob/HEAD/skills/securing-github-actions-workflows/SKILL.md","repo_owner":"mukul975","body_sha256":"d96876de8338b923a87310ea377a9613e7c68a35ae1d7f41000123673bd4637a","cluster_key":"d7eb558275678050d75b25f3a970b435316d971b774be54ebcd9c6728639d5a3","clean_bundle":{"format":"clean-skill-bundle-v1","source":"mukul975/anthropic-cybersecurity-skills/skills/securing-github-actions-workflows/SKILL.md","attachments":[{"id":"c94bde20-a53e-5ed6-9782-961af2d9b56e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c94bde20-a53e-5ed6-9782-961af2d9b56e/attachment.md","path":"assets/template.md","size":1016,"sha256":"b2aa3134c958e7c72012f1df2af5a3a2157892a8b273ed8828020e879a730198","contentType":"text/markdown; charset=utf-8"},{"id":"2774f2c9-51ce-561b-b6d7-72e893fc8b04","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2774f2c9-51ce-561b-b6d7-72e893fc8b04/attachment.md","path":"references/api-reference.md","size":1844,"sha256":"22eb1d65e280a75caa837e24e5ed09246e6384c907cc0a06fdb6e22866e64fb9","contentType":"text/markdown; charset=utf-8"},{"id":"678b86b8-b71d-58ec-87fb-25e32efbeda8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/678b86b8-b71d-58ec-87fb-25e32efbeda8/attachment.md","path":"references/standards.md","size":1152,"sha256":"0d302c6ecc0a42b1ccabc2453afe1b34a46f7558e80f907ffd7e00d180c78524","contentType":"text/markdown; charset=utf-8"},{"id":"66be1342-f857-5779-a9ce-69cc398cbabf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/66be1342-f857-5779-a9ce-69cc398cbabf/attachment.md","path":"references/workflows.md","size":1192,"sha256":"1ca92cea132afc6e87dc653c85ca9c935663fcc3b527e3e544432d44c8db9d15","contentType":"text/markdown; charset=utf-8"},{"id":"35298a5b-a756-559b-ab78-6d18d59b1a2e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/35298a5b-a756-559b-ab78-6d18d59b1a2e/attachment.py","path":"scripts/agent.py","size":8248,"sha256":"530b2e57d5d584b6dcfb84165dddfe9c454599dd58cd1253a3b57c1b6d6f6390","contentType":"text/x-python; charset=utf-8"},{"id":"72ebd9e3-535e-5efe-899e-ec0482ae7550","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/72ebd9e3-535e-5efe-899e-ec0482ae7550/attachment.py","path":"scripts/process.py","size":7533,"sha256":"2b3cac28679287eea1957c1d9d1c000f42cacf351164b8afcb3bba3c22e53927","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"cce41d312ab5bfbe67829f7fb89da6a94b0e360b9d53d5575bcd05737440d3d1","attachment_count":6,"text_attachments":6,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":2,"skill_md_path":"skills/securing-github-actions-workflows/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"security","category_label":"Security"},"exact_dupes_collapsed_into_this":1},"license":"Apache-2.0","version":"v1","category":"security","nist_csf":["PR.PS-01","GV.SC-07","ID.IM-04","PR.PS-04"],"subdomain":"devsecops","import_tag":"clean-skills-v1","description":"This skill covers hardening GitHub Actions workflows against supply chain attacks, credential theft, and privilege escalation. It addresses pinning actions to SHA digests, minimizing GITHUB_TOKEN permissions, protecting secrets from exfiltration, preventing script injection in workflow expressions, and implementing required reviewers for workflow changes.\n"}},"renderedAt":1782986242394}

Securing GitHub Actions Workflows When to Use - When GitHub Actions is the CI/CD platform and workflows need hardening against supply chain attacks - When workflows handle secrets, deploy to production, or have elevated permissions - When preventing script injection via untrusted PR titles, branch names, or commit messages - When requiring audit trails and approval gates for workflow modifications - When third-party actions pose supply chain risk through mutable version tags Do not use for securing other CI/CD platforms (see platform-specific hardening guides), for application vulnerability s…