
Securing Github Actions Workflows
- 113 installs
- 27.3k repo stars
- Updated August 2, 2026
- mukul975/anthropic-cybersecurity-skills
Helps with security tasks.
About
securing-github-actions-workflows is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
- securing-github-actions-workflows
- Security
- AI-coding skill
Securing Github Actions Workflows by the numbers
- 113 all-time installs (skills.sh)
- +13 installs in the week ending Jul 17, 2026 (Skillselion tracking)
- Ranked #980 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mukul975/anthropic-cybersecurity-skills --skill securing-github-actions-workflowsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 113 |
|---|---|
| repo stars | ★ 27.3k |
| Last updated | August 2, 2026 |
| Repository | mukul975/anthropic-cybersecurity-skills ↗ |
What it does
Helps with security tasks.
Files
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 scanning (use SAST/DAST), or for secret detection in code (use Gitleaks).
Prerequisites
- GitHub repository with GitHub Actions enabled
- GitHub organization admin access for organization-level settings
- Understanding of GitHub Actions workflow syntax and events
Workflow
Step 1: Pin Actions to SHA Digests
# INSECURE: Mutable tag can be overwritten by attacker
- uses: actions/checkout@v4
# SECURE: Pinned to immutable SHA digest
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# Use Dependabot to auto-update pinned SHAs
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"Step 2: Minimize GITHUB_TOKEN Permissions
# Set restrictive default permissions at workflow level
name: CI Pipeline
permissions: {} # Start with no permissions
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # Only what's needed
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
permissions:
contents: read
deployments: write
id-token: write # For OIDC-based cloud auth
steps:
- name: Deploy
run: echo "deploying"Step 3: Prevent Script Injection
# VULNERABLE: User-controlled input in run step
- run: echo "PR title is ${{ github.event.pull_request.title }}"
# SECURE: Use environment variable (properly escaped by shell)
- name: Process PR
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
echo "PR title is ${PR_TITLE}"
echo "PR body is ${PR_BODY}"
# SECURE: Use actions/github-script for complex operations
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
with:
script: |
const title = context.payload.pull_request.title;
console.log(`PR title: ${title}`);Step 4: Secure Fork Pull Request Handling
# DANGEROUS: pull_request_target runs with base repo permissions
# on: pull_request_target # AVOID unless absolutely necessary
# SAFE: pull_request runs in fork context with limited permissions
on:
pull_request:
branches: [main]
# If pull_request_target is required, never checkout PR code:
on:
pull_request_target:
types: [labeled]
jobs:
safe-job:
if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# NEVER do: actions/checkout with ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# This checks out the BASE branch, not the PRStep 5: Protect Secrets and Environment Variables
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires approval
steps:
- name: Deploy with secret
env:
# Secrets are masked in logs automatically
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
# Never echo secrets
# echo "$DEPLOY_KEY" # BAD
deploy-tool --key-file <(echo "$DEPLOY_KEY")
- name: Audit secret access
run: |
# Log that secret was used without exposing it
echo "::notice::Deploy key accessed for production deployment"Step 6: Implement Workflow Change Controls
# Require CODEOWNERS approval for workflow changes
# .github/CODEOWNERS
.github/workflows/ @security-team @platform-team
.github/actions/ @security-team @platform-team
# Organization settings:
# 1. Settings > Actions > General > Fork PR policies
# - Require approval for first-time contributors
# - Require approval for all outside collaborators
# 2. Settings > Actions > General > Workflow permissions
# - Read repository contents and packages permissions
# - Do NOT allow GitHub Actions to create and approve PRsKey Concepts
| Term | Definition |
|---|---|
| SHA Pinning | Referencing GitHub Actions by their immutable commit SHA instead of mutable version tags |
| Script Injection | Attack where untrusted input (PR title, branch name) is interpolated into shell commands |
| GITHUB_TOKEN | Automatically generated token with configurable permissions scoped to the current repository |
| pull_request_target | Dangerous event trigger that runs in the base repo context with full permissions on fork PRs |
| Environment Protection | GitHub feature requiring manual approval before jobs accessing an environment can run |
| CODEOWNERS | File defining required reviewers for specific paths including workflow files |
| OIDC Federation | Using GitHub's OIDC token to authenticate to cloud providers without storing long-lived credentials |
Tools & Systems
- Dependabot: Automated dependency updater that keeps pinned action SHAs current
- StepSecurity Harden Runner: GitHub Action that monitors and restricts outbound network calls from workflows
- actionlint: Linter for GitHub Actions workflow files that detects security issues
- allstar: GitHub App by OpenSSF that enforces security policies on repositories
- scorecard: OpenSSF tool that evaluates supply chain security practices including CI/CD
Common Scenarios
Scenario: Preventing Supply Chain Attack via Compromised Third-Party Action
Context: A widely-used GitHub Action is compromised and its v3 tag is updated to include credential-stealing code. Repositories using @v3 automatically pull the malicious version.
Approach: 1. Pin all actions to SHA digests immediately across all repositories 2. Configure Dependabot for github-actions ecosystem to manage SHA updates 3. Restrict GITHUB_TOKEN permissions so even compromised actions have minimal access 4. Add StepSecurity harden-runner to detect anomalous outbound network calls 5. Review all third-party actions and replace unnecessary ones with inline scripts 6. Require CODEOWNERS approval for any changes to .github/workflows/
Pitfalls: SHA pinning without Dependabot means missing legitimate security updates to actions. Overly restrictive permissions can break legitimate workflows. Using pull_request_target for label-based gating still exposes secrets if the workflow checks out PR code.
Output Format
GitHub Actions Security Audit
================================
Repository: org/web-application
Date: 2026-02-23
WORKFLOW ANALYSIS:
Total workflows: 8
Total action references: 34
SHA PINNING:
[FAIL] 12/34 actions use mutable tags instead of SHA digests
- .github/workflows/ci.yml: actions/setup-node@v4
- .github/workflows/deploy.yml: aws-actions/configure-aws-credentials@v4
PERMISSIONS:
[FAIL] 3/8 workflows have no explicit permissions (inherit default)
[WARN] 1/8 workflows request write-all permissions
SCRIPT INJECTION:
[FAIL] 2 workflow steps interpolate user input directly
- .github/workflows/pr-check.yml:23: ${{ github.event.pull_request.title }}
SECRETS:
[PASS] No secrets exposed in workflow logs
[PASS] All production deployments use environment protection
SCORE: 6/10 (Remediate 5 HIGH findings)GitHub Actions Security Templates
Hardened Workflow Template
name: Secure CI Pipeline
permissions: {}
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: audit
- name: Build
run: make build
- name: Test
run: make testDependabot for Actions
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"CODEOWNERS for Workflow Protection
# .github/CODEOWNERS
.github/workflows/ @org/security-team @org/platform-team
.github/actions/ @org/security-team
.github/dependabot.yml @org/platform-team
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please do not remove or change
the license header comment from a contributed file except when
necessary.
Copyright 2026 mukul975
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
API Reference: Securing GitHub Actions Workflows
Security Checks
| Check | Risk | Severity |
|---|---|---|
| Unpinned actions (mutable tags) | Supply chain attack via tag overwrite | Medium |
| Missing permissions block | Inherits overly broad defaults | Medium |
| write-all permissions | Excessive token scope | High |
| Script injection in run steps | Code execution via PR title/body | High |
| pull_request_target trigger | Fork code runs with base permissions | High |
| Secrets in workflow logs | Credential exposure | Critical |
Dangerous Expression Contexts
| Context | Risk |
|---|---|
github.event.pull_request.title | Attacker-controlled PR title |
github.event.pull_request.body | Attacker-controlled PR body |
github.event.issue.title | Attacker-controlled issue title |
github.event.comment.body | Attacker-controlled comment |
github.head_ref | Attacker-controlled branch name |
SHA Pinning Format
| Format | Security |
|---|---|
actions/checkout@v4 | Insecure - mutable tag |
actions/checkout@b4ffde65f... | Secure - immutable SHA |
Permission Scopes
| Scope | Values |
|---|---|
| contents | read, write |
| actions | read, write |
| deployments | read, write |
| id-token | write (for OIDC) |
| security-events | write |
| pull-requests | read, write |
Python Libraries
| Library | Version | Purpose |
|---|---|---|
yaml | PyYAML >=6.0 | Parse workflow YAML |
re | stdlib | Pattern matching |
json | stdlib | Report output |
pathlib | stdlib | File discovery |
References
- GitHub Actions Security Hardening: https://docs.github.com/en/actions/security-guides
- StepSecurity Harden Runner: https://github.com/step-security/harden-runner
- actionlint: https://github.com/rhysd/actionlint
Standards Reference: Securing GitHub Actions
NIST SSDF (SP 800-218)
PS.1: Protect All Forms of Code
- Workflows are code and must be reviewed and protected
- Pin action dependencies to SHA digests
- Minimize GITHUB_TOKEN permissions
CIS Software Supply Chain Security
- BD-1: Define security requirements for build processes
- BD-2: Automate security validation of build configurations
- BD-3: Pin all external dependencies to immutable references
OWASP CI/CD Top 10 Risks
| Risk | GitHub Actions Mitigation |
|---|---|
| CICD-SEC-1: Insufficient Flow Control | Environment protection rules, CODEOWNERS |
| CICD-SEC-3: Dependency Chain Abuse | SHA pinning of actions |
| CICD-SEC-4: Poisoned Pipeline Execution | Restrict pull_request_target, input sanitization |
| CICD-SEC-6: Credential Hygiene | OIDC federation, minimal GITHUB_TOKEN scope |
| CICD-SEC-9: Artifact Integrity | Sign artifacts in workflows |
SLSA Framework
- Level 2: Hosted build service (GitHub Actions qualifies)
- Level 3: Hardened build platform with isolation guarantees
- Workflow hardening prevents provenance falsification
Workflow Reference: Securing GitHub Actions
Hardening Checklist
1. Pin all actions to SHA digests 2. Set restrictive default permissions 3. Sanitize all user-controlled inputs 4. Never use pull_request_target with PR checkout 5. Enable environment protection for production 6. Configure CODEOWNERS for workflow files 7. Enable Dependabot for github-actions 8. Audit third-party actions quarterly 9. Use OIDC instead of long-lived cloud credentials 10. Add harden-runner for network monitoring
Permission Scoping Reference
| Permission | Use Case |
|---|---|
| contents: read | Checkout code |
| contents: write | Create releases, push tags |
| security-events: write | Upload SARIF results |
| packages: write | Push container images |
| deployments: write | Create deployment status |
| id-token: write | OIDC cloud authentication |
| pull-requests: write | Comment on PRs |
Script Injection Prevention
# DANGEROUS patterns to avoid:
run: echo "${{ github.event.issue.title }}"
run: echo "${{ github.event.comment.body }}"
run: echo "${{ github.head_ref }}"
# SAFE alternatives:
env:
TITLE: ${{ github.event.issue.title }}
run: echo "${TITLE}"#!/usr/bin/env python3
"""Agent for securing GitHub Actions workflows.
Audits GitHub Actions workflow files for security issues including
unpinned actions, excessive permissions, script injection risks,
dangerous triggers, and missing secret protections.
"""
import json
import re
import sys
from pathlib import Path
from datetime import datetime
try:
import yaml
except ImportError:
yaml = None
class GitHubActionsSecurityAgent:
"""Audits GitHub Actions workflows for security vulnerabilities."""
def __init__(self, repo_path=".", output_dir="./gha_audit"):
self.repo_path = Path(repo_path)
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.findings = []
def _load_workflow(self, path):
if yaml:
with open(path) as f:
return yaml.safe_load(f)
with open(path) as f:
return {"raw": f.read()}
def find_workflows(self):
"""Discover all workflow files in the repository."""
wf_dir = self.repo_path / ".github" / "workflows"
if not wf_dir.exists():
return []
return sorted(wf_dir.glob("*.yml")) + sorted(wf_dir.glob("*.yaml"))
def check_sha_pinning(self, workflow_path, content):
"""Check if actions are pinned to SHA digests."""
unpinned = []
raw = content.get("raw", "") if "raw" in content else ""
if not raw:
try:
raw = Path(workflow_path).read_text()
except Exception:
return unpinned
for line_num, line in enumerate(raw.splitlines(), 1):
m = re.search(r'uses:\s+([^@\s]+)@([^\s#]+)', line)
if m:
action, ref = m.group(1), m.group(2)
if not re.match(r'^[a-f0-9]{40}$', ref):
unpinned.append({
"action": action,
"ref": ref,
"line": line_num,
"file": str(workflow_path),
})
self.findings.append({
"severity": "medium",
"type": "Unpinned Action",
"detail": f"{action}@{ref} at line {line_num}",
"file": str(workflow_path),
})
return unpinned
def check_permissions(self, workflow_path, content):
"""Check for overly permissive GITHUB_TOKEN permissions."""
issues = []
if not isinstance(content, dict) or "raw" in content:
return issues
top_perms = content.get("permissions")
if top_perms is None:
issues.append({
"issue": "No top-level permissions defined (inherits defaults)",
"file": str(workflow_path),
})
self.findings.append({
"severity": "medium",
"type": "Missing Permissions",
"detail": "Workflow has no permissions block",
"file": str(workflow_path),
})
if top_perms == "write-all" or (isinstance(top_perms, dict) and
top_perms.get("contents") == "write" and
top_perms.get("actions") == "write"):
issues.append({"issue": "Overly permissive write-all", "file": str(workflow_path)})
self.findings.append({
"severity": "high",
"type": "Excessive Permissions",
"detail": "write-all permissions granted",
"file": str(workflow_path),
})
return issues
def check_script_injection(self, workflow_path, content):
"""Check for user-controlled input in run steps (script injection)."""
injections = []
raw = content.get("raw", "") if "raw" in content else ""
if not raw:
try:
raw = Path(workflow_path).read_text()
except Exception:
return injections
dangerous_contexts = [
"github.event.pull_request.title",
"github.event.pull_request.body",
"github.event.issue.title",
"github.event.issue.body",
"github.event.comment.body",
"github.event.review.body",
"github.head_ref",
]
in_run = False
for line_num, line in enumerate(raw.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("run:") or stripped.startswith("run: |"):
in_run = True
elif in_run and not stripped.startswith("-") and not stripped.startswith("#"):
for ctx in dangerous_contexts:
if f"${{{{ {ctx}" in line or f"${{{{{ctx}" in line:
injections.append({
"context": ctx,
"line": line_num,
"file": str(workflow_path),
})
self.findings.append({
"severity": "high",
"type": "Script Injection",
"detail": f"{ctx} in run step at line {line_num}",
"file": str(workflow_path),
})
if stripped and not stripped.startswith("-") and not stripped.startswith("#") and ":" in stripped and not stripped.startswith("run"):
in_run = False
return injections
def check_dangerous_triggers(self, workflow_path, content):
"""Check for dangerous event triggers."""
issues = []
if not isinstance(content, dict) or "raw" in content:
raw = content.get("raw", "")
if "pull_request_target" in raw:
issues.append({"trigger": "pull_request_target", "file": str(workflow_path)})
self.findings.append({
"severity": "high",
"type": "Dangerous Trigger",
"detail": "pull_request_target allows fork code to run with base permissions",
"file": str(workflow_path),
})
return issues
on_block = content.get("on", content.get(True, {}))
if isinstance(on_block, dict) and "pull_request_target" in on_block:
issues.append({"trigger": "pull_request_target", "file": str(workflow_path)})
self.findings.append({
"severity": "high",
"type": "Dangerous Trigger",
"detail": "pull_request_target trigger used",
"file": str(workflow_path),
})
return issues
def audit_all(self):
"""Run all security checks on all workflow files."""
workflows = self.find_workflows()
results = []
for wf in workflows:
content = self._load_workflow(wf)
unpinned = self.check_sha_pinning(wf, content)
perms = self.check_permissions(wf, content)
injections = self.check_script_injection(wf, content)
triggers = self.check_dangerous_triggers(wf, content)
results.append({
"workflow": str(wf),
"unpinned_actions": len(unpinned),
"permission_issues": len(perms),
"script_injections": len(injections),
"dangerous_triggers": len(triggers),
})
return results
def generate_report(self):
audit = self.audit_all()
report = {
"report_date": datetime.utcnow().isoformat(),
"repository": str(self.repo_path),
"workflows_scanned": len(audit),
"audit_summary": audit,
"findings": self.findings,
"total_findings": len(self.findings),
}
out = self.output_dir / "gha_security_report.json"
with open(out, "w") as f:
json.dump(report, f, indent=2)
print(json.dumps(report, indent=2))
return report
def main():
repo = sys.argv[1] if len(sys.argv) > 1 else "."
agent = GitHubActionsSecurityAgent(repo)
agent.generate_report()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
GitHub Actions Workflow Security Audit Script
Analyzes workflow files for security issues including unpinned actions,
excessive permissions, script injection risks, and insecure patterns.
Usage:
python process.py --workflows-dir .github/workflows/ --output audit-report.json
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import yaml
@dataclass
class SecurityFinding:
file: str
line: int
check: str
severity: str
message: str
remediation: str
SHA_PATTERN = re.compile(r"@[0-9a-f]{40}")
TAG_PATTERN = re.compile(r"@v?\d+(\.\d+)*$")
INJECTION_PATTERN = re.compile(r"\$\{\{\s*github\.event\.(issue|pull_request|comment|review)\.\w+")
DANGEROUS_CONTEXTS = [
"github.event.issue.title",
"github.event.issue.body",
"github.event.pull_request.title",
"github.event.pull_request.body",
"github.event.comment.body",
"github.event.review.body",
"github.head_ref",
]
def load_workflow(filepath: str) -> dict:
"""Load a GitHub Actions workflow YAML file."""
try:
with open(filepath, "r") as f:
return yaml.safe_load(f) or {}
except (yaml.YAMLError, FileNotFoundError):
return {}
def check_action_pinning(workflow: dict, filepath: str) -> list:
"""Check if actions are pinned to SHA digests."""
findings = []
filename = os.path.basename(filepath)
for job_name, job in workflow.get("jobs", {}).items():
for i, step in enumerate(job.get("steps", [])):
uses = step.get("uses", "")
if not uses or uses.startswith("./"):
continue
if not SHA_PATTERN.search(uses):
findings.append(SecurityFinding(
file=filename, line=0,
check="ACTION_PINNING",
severity="HIGH",
message=f"Job '{job_name}' step {i}: '{uses}' not pinned to SHA digest",
remediation=f"Pin to SHA: {uses.split('@')[0]}@<commit-sha>"
))
return findings
def check_permissions(workflow: dict, filepath: str) -> list:
"""Check for overly permissive GITHUB_TOKEN permissions."""
findings = []
filename = os.path.basename(filepath)
top_perms = workflow.get("permissions")
if top_perms is None:
findings.append(SecurityFinding(
file=filename, line=0,
check="PERMISSIONS",
severity="MEDIUM",
message="No top-level permissions defined. Inherits default (may be write-all).",
remediation="Add 'permissions: {}' at workflow level and grant per-job."
))
elif top_perms == "write-all" or (isinstance(top_perms, dict) and
all(v == "write" for v in top_perms.values())):
findings.append(SecurityFinding(
file=filename, line=0,
check="PERMISSIONS",
severity="HIGH",
message="Workflow has write-all permissions.",
remediation="Restrict to minimum required permissions per job."
))
return findings
def check_script_injection(workflow: dict, filepath: str) -> list:
"""Check for script injection via user-controlled inputs."""
findings = []
filename = os.path.basename(filepath)
for job_name, job in workflow.get("jobs", {}).items():
for i, step in enumerate(job.get("steps", [])):
run_cmd = step.get("run", "")
if not run_cmd:
continue
for ctx in DANGEROUS_CONTEXTS:
if f"${{{{ {ctx}" in run_cmd or f"${{{{{ctx}" in run_cmd:
findings.append(SecurityFinding(
file=filename, line=0,
check="SCRIPT_INJECTION",
severity="CRITICAL",
message=f"Job '{job_name}' step {i}: '{ctx}' interpolated in run step",
remediation="Use env variable: env: VAR: ${{ " + ctx + " }} then ${VAR}"
))
return findings
def check_pr_target(workflow: dict, filepath: str) -> list:
"""Check for dangerous pull_request_target usage."""
findings = []
filename = os.path.basename(filepath)
triggers = workflow.get("on", {})
if isinstance(triggers, dict) and "pull_request_target" in triggers:
for job_name, job in workflow.get("jobs", {}).items():
for step in job.get("steps", []):
uses = step.get("uses", "")
if "checkout" in uses:
with_ref = step.get("with", {}).get("ref", "")
if "pull_request" in with_ref or "head" in with_ref:
findings.append(SecurityFinding(
file=filename, line=0,
check="PR_TARGET_CHECKOUT",
severity="CRITICAL",
message=f"Job '{job_name}': pull_request_target with PR code checkout",
remediation="Never checkout PR code in pull_request_target workflows."
))
return findings
def main():
parser = argparse.ArgumentParser(description="GitHub Actions Security Audit")
parser.add_argument("--workflows-dir", required=True)
parser.add_argument("--output", default="actions-security-report.json")
parser.add_argument("--fail-on-findings", action="store_true")
args = parser.parse_args()
workflows_dir = os.path.abspath(args.workflows_dir)
all_findings = []
workflow_files = list(Path(workflows_dir).glob("*.yml")) + list(Path(workflows_dir).glob("*.yaml"))
print(f"[*] Auditing {len(workflow_files)} workflow files in {workflows_dir}")
for wf_path in workflow_files:
workflow = load_workflow(str(wf_path))
if not workflow:
continue
all_findings.extend(check_action_pinning(workflow, str(wf_path)))
all_findings.extend(check_permissions(workflow, str(wf_path)))
all_findings.extend(check_script_injection(workflow, str(wf_path)))
all_findings.extend(check_pr_target(workflow, str(wf_path)))
severity_counts = {}
for f in all_findings:
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
report = {
"metadata": {
"directory": workflows_dir,
"date": datetime.now(timezone.utc).isoformat(),
"workflows_scanned": len(workflow_files)
},
"summary": {
"total_findings": len(all_findings),
"severity_counts": severity_counts
},
"findings": [
{"file": f.file, "check": f.check, "severity": f.severity,
"message": f.message, "remediation": f.remediation}
for f in sorted(all_findings,
key=lambda x: {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}.get(x.severity, 4))
]
}
output_path = os.path.abspath(args.output)
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
print(f"[*] Report: {output_path}")
for f in all_findings:
print(f" [{f.severity}] {f.file}: {f.message}")
passed = len(all_findings) == 0
print(f"\n[{'PASS' if passed else 'FAIL'}] {len(all_findings)} security findings")
if args.fail_on_findings and not passed:
sys.exit(1)
if __name__ == "__main__":
main()