ghast is a security auditing and remediation tool for GitHub Actions workflows. It detects misconfigurations, security vulnerabilities, and anti-patterns in your workflows based on industry best practices.
Inspired by this security guide from Wiz, ghast helps prevent recent high-profile supply chain attacks like those affecting tj-actions.
- Security Scanning: Detect critical security vulnerabilities like Poisoned Pipeline Execution (PPE)
- Workflow Hardening: Enforce least-privilege permissions and proper action pinning
- Auto-Remediation: Fix common security issues automatically
- Multiple Output Formats: Console, JSON, SARIF (for GitHub Code Scanning), and HTML reports
- CI/CD Integration: Run in CI/CD pipelines with configurable severity thresholds
- Interactive Mode: Review and approve fixes one by one
- Comprehensive Rules: 15+ security rules based on industry best practices
| Category | Rules |
|---|---|
| Critical | Poisoned Pipeline Execution (PPE), Exposed Secrets, Token Security |
| High | Command Injection, Environment Variable Injection, Overly Permissive Permissions |
| Medium | Action Pinning, Deprecated Actions, Reusable Workflow Safety |
| Low | Timeouts, Shell Specifications, Workflow Names |
| ID | Category | Default Severity |
|---|---|---|
permissions |
security | HIGH |
poisoned_pipeline_execution |
security | CRITICAL |
command_injection |
security | HIGH |
environment_injection |
security | HIGH |
token_security |
security | HIGH |
action_pinning |
security | MEDIUM |
timeout |
best-practice | LOW |
shell_specification |
best-practice | LOW |
workflow_name |
best-practice | LOW |
deprecated_actions |
best-practice | MEDIUM |
continue_on_error |
best-practice | MEDIUM |
reusable_workflow_inputs |
best-practice | MEDIUM |
Install the latest release from PyPI:
pip install ghastTo install from source:
git clone https://github.com/seanwevans/ghast.git
cd ghast
pip install -e .Scan your GitHub Actions workflows for security issues:
# Scan a repository
ghast scan /path/to/repo
# Apply automatic fixes
ghast fix /path/to/repo
# Generate a comprehensive security report
ghast report /path/to/repo --output security-report.html
# Integration with GitHub Code Scanning
ghast scan /path/to/repo --output sarif --output-file ghast-results.sarifname: Workflow security
on: [push, pull_request]
permissions: read-all
jobs:
ghast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: seanwevans/ghast@v1
with:
severity-threshold: HIGH| Input | Default | Description |
|---|---|---|
path |
. |
Repository root, or a single workflow file, to scan |
severity-threshold |
LOW |
Minimum severity to report and fail on |
config |
— | Path to a ghast YAML config file |
disable |
— | Whitespace-separated rule ids to disable |
output |
text |
text, json, sarif or html |
output-file |
— | Write results here instead of stdout |
fail-on-findings |
true |
Fail the step when findings are reported |
strict |
false |
Enable strict mode |
version |
— | PyPI version to install; defaults to the action's own source |
python-version |
— | Set up this Python; defaults to the runner's |
| Output | Description |
|---|---|
exit-code |
0 clean, 1 findings, 2 ghast failed to run |
findings |
true when findings at or above the threshold were reported |
- uses: seanwevans/ghast@v1
with:
output: sarif
output-file: ghast.sarif
fail-on-findings: false # let Code Scanning own the gate
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ghast.sarifThat job needs security-events: write.
Add to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/seanwevans/ghast
rev: v0.2.0
hooks:
- id: ghastUse ghast-strict instead to fail only on HIGH and above. Both hooks run
whenever a file under .github/workflows/ changes and scan the whole
repository, since rules like permissions reason about a complete workflow
rather than a changed line.
Install test dependencies and run the suite with:
pip install -e .[test]
pytestCoverage is measured with branch coverage and a floor of 98%. The floor is a regression guard, not a target: the remaining uncovered branches are mostly loop exits and defensive guards, and they should be closed by tests that have a reason to exist rather than by tests written to move the number. An earlier 100% line-coverage requirement was fully satisfied while five rule helpers that no code path could reach sat behind tests written only to cover them.
Set up the project's own pre-commit hooks to format and lint before each commit:
pip install pre-commit
pre-commit installRun all hooks against the entire codebase with:
pre-commit run --all-files🔍 Scanning .github/workflows/ci.yml...
File: .github/workflows/ci.yml
🚨 CRITICAL: Poisoned Pipeline Execution vulnerability: job 'build' uses pull_request_target trigger with checkout of untrusted code
Rule: poisoned_pipeline_execution
File: .github/workflows/ci.yml:15
Remediation: Use pull_request trigger instead, or if pull_request_target is required, do not check out untrusted code
❗ HIGH: Missing explicit permissions at workflow level
Rule: permissions
File: .github/workflows/ci.yml
Remediation: Add 'permissions: read-all' at the top level of the workflow
⚠️ MEDIUM: Step 2 in job 'build' is not pinned to a specific commit SHA: actions/checkout@v3
Rule: action_pinning
File: .github/workflows/ci.yml:18
Remediation: Pin to a specific commit SHA for better security
✅ Fixed permissions issue in .github/workflows/ci.yml
# Basic scan
ghast scan /path/to/repo
# Only show high and critical issues
ghast scan /path/to/repo --severity-threshold HIGH
# Output as JSON
ghast scan /path/to/repo --output json
# Write results to a file
ghast scan /path/to/repo --output-file results.txt
# Show detailed information for each finding
ghast scan /path/to/repo --verboseghast scan /path/to/repo # .github/workflows/*.yml plus every action.yml
ghast scan ./workflows # a bare directory of workflow files
ghast scan .github/workflows/ci.yml # a single fileComposite actions are scanned too. An action.yml runs steps with the same
supply-chain exposure as a workflow — unpinned uses, unspecified shells,
untrusted ${{ }} interpolation — so ghast checks them with the same
step-level rules:
Found 2 file(s) to scan (1 composite action(s))
File: actions/build/action.yml
❗ HIGH: Untrusted issue title/body interpolated into a shell command in job 'runs' step 3
⚠️ MEDIUM: Step 1 in job 'runs' is not pinned to a specific commit SHA: actions/checkout@v3
Steps are reported under the job name runs, matching the runs: key they
live under in the action file. Rules that describe a workflow rather than a
step — permissions, poisoned_pipeline_execution, timeout,
workflow_name, reusable_workflow_inputs — are not reported for actions,
since an action declares no triggers, holds no permissions, and cannot set a
job timeout. JavaScript and Docker actions have no step surface and produce no
findings.
node_modules, .venv, .git and similar directories are skipped: vendored
actions belong to their upstream.
ghast scan distinguishes "your workflows have problems" from "ghast could
not run", so it can be used as a required CI check:
| Code | Meaning |
|---|---|
0 |
No findings at or above the severity threshold |
1 |
Findings at or above the severity threshold |
2 |
ghast could not complete the run — bad config, no workflows found, unreadable file, invalid arguments |
The findings notice is written to stderr, so --output json and
--output sarif write clean, parseable documents to stdout:
ghast scan . --output json | jq '.findings[] | select(.severity == "CRITICAL")'To fail a build only on serious issues while still printing everything:
ghast scan . --severity-threshold HIGH# Apply automatic fixes
ghast fix /path/to/repo
# Preview fixes without applying
ghast fix /path/to/repo --dry-run
# Interactively review and apply fixes
ghast fix /path/to/repo --interactive
# Fix only critical issues
ghast fix /path/to/repo --severity-threshold CRITICAL
# Keep a .bak copy of every file that changed
ghast fix /path/to/repo --backupFixes are written in place with no backup by default: workflows live in version
control, and the rewritten document is re-parsed and checked before it replaces
anything. Pass --backup if you want a .bak alongside each changed file.
# Use a custom config file
ghast scan /path/to/repo --config ghast.yml
# Generate a default config file
ghast config --generate --output ghast.yml
# Disable specific rules (by rule ID; run `ghast rules` to list them)
ghast scan /path/to/repo --disable token_security --disable deprecated_actions# List all available rules
ghast rules
# Generate a comprehensive report
ghast report /path/to/repo --output report.html
A repository with existing findings cannot turn ghast into a blocking check if every run is red. Two ways to accept what is already there.
Annotate the specific line, so the justification lives next to what it explains:
steps:
# A trailing comment applies to its own line
- uses: actions/checkout@v3 # ghast: ignore[action_pinning]
# A comment on its own line applies to the next line with content,
# which leaves room for a real explanation.
# ghast: ignore[action_pinning] -- vendored fork, tracked in #42
- uses: internal/checkout@v3# ghast: ignore— every rule on that line# ghast: ignore[rule_id, other_rule]— only those rules# ghast: ignore-file/# ghast: ignore-file[rule_id]— the whole file- Anything after
--is for humans; ghast does not read it
A trailing comment never applies to the following line, so one annotation cannot quietly silence the step after it. Every scan prints how many findings were suppressed, so they stay visible.
Record everything currently outstanding, then gate on new findings only:
# Record the current state and commit the result
ghast baseline . --output ghast-baseline.json
# Later scans report only findings not in the baseline
ghast scan . --baseline ghast-baseline.json$ ghast scan . --baseline ghast-baseline.json
Ignored 12 finding(s) recorded in ghast-baseline.json
⚠️ MEDIUM: Step 3 in job 'build' is not pinned to a specific commit SHA: some/new-action@v1Findings are matched by a fingerprint of the rule, the repository-relative path, and the message — not the line number — so reformatting a workflow does not invalidate the file's entries. Messages for step-indexed rules do embed the step number, so inserting a step above an existing finding will report it as new; regenerate the baseline when that happens.
Shrink the baseline as you fix things by regenerating it. It is a burndown list, not a permanent exemption.
ghast can be configured using a YAML configuration file.
Config keys are rule IDs — the same names shown in findings and listed by
ghast rules. There is one name per rule, everywhere.
A complete example with default settings is available in examples/ghast.yml.
Generate a fresh one at any time with ghast config --generate --output ghast.yml.
# Enable/disable rules, keyed by rule ID
permissions: true
poisoned_pipeline_execution: true
command_injection: true
environment_injection: false # off by default; set true to enable
token_security: true
action_pinning: true
timeout: true
shell_specification: true
workflow_name: true
deprecated_actions: true
continue_on_error: true
reusable_workflow_inputs: true
# Configure severity thresholds
severity_thresholds:
timeout: "LOW"
token_security: "HIGH"
poisoned_pipeline_execution: "CRITICAL"
# Auto-fix settings
auto_fix:
enabled: true
rules:
timeout: true
shell_specification: true
deprecated_actions: true
workflow_name: true
# Default timeouts for auto-fix
default_timeout_minutes: 15
# Default version replacements for deprecated actions
default_action_versions:
actions/checkout@v1: actions/checkout@v3
actions/setup-python@v1: actions/setup-python@v4Earlier versions used a separate set of check_* config keys that did not
match the rule IDs. Most of them silently had no effect. The old names are
still accepted and now do what they always claimed to, but ghast prints a
warning telling you what to rename them to:
| Old key | Rule ID |
|---|---|
check_timeout |
timeout |
check_shell |
shell_specification |
check_deprecated |
deprecated_actions |
check_workflow_name |
workflow_name |
check_continue_on_error |
continue_on_error |
check_tokens |
token_security |
check_reusable_inputs |
reusable_workflow_inputs |
check_ppe_vulnerabilities |
poisoned_pipeline_execution |
check_command_injection |
command_injection |
check_env_injection |
environment_injection |
check_runs_on and check_inline_bash named rules that were never
implemented. They still load, with a warning, and can be deleted.
GitHub Actions workflows can introduce security risks if not properly configured:
- Poisoned Pipeline Execution (PPE): Occurs when high-privilege triggers run untrusted code with access to secrets. ghast treats
pull_request_target,workflow_run,issue_comment,pull_request_review,pull_request_review_commentanddiscussion_commentas high-privilege. - Over-privileged Workflows: Workflows with unnecessary write permissions increase attack surface
- Unpinned Actions: Non-SHA-pinned actions can change unexpectedly, introducing malicious code
- Command Injection:
${{ }}is substituted into arun:script before the shell sees it, so untrusted values are executed rather than passed as data - Token Exposure: Hardcoded tokens or
toJSON(secrets)usage can leak sensitive credentials
The command_injection rule flags attacker-controllable values interpolated
into a run: block or into an action input that is executed as code (such as
actions/github-script's script). The tracked contexts are:
github.event.issue.title/.body · github.event.comment.body ·
github.event.discussion.title/.body · github.event.discussion_comment.body ·
github.event.review.body · github.event.review_comment.body ·
github.event.pull_request.title/.body ·
github.event.pull_request.head.ref/.label ·
github.event.pull_request.head.repo.default_branch/.description/.homepage ·
github.event.head_commit.message/.author.* · github.event.commits[*].message/.author.* ·
github.event.workflow_run.head_branch/.display_title/.head_commit.message ·
github.event.pages[*].page_name · github.head_ref ·
github.event.inputs.* · inputs.*
Passing untrusted data through env: and referencing it as a quoted shell
variable is GitHub's recommended mitigation, so ghast deliberately does not
flag that pattern.
ghast helps identify and remediate these risks before they can be exploited.
Releases publish to PyPI via trusted publishing, so there is no long-lived API token stored in the repository. Tagging is the whole release process:
git tag v0.3.0
git push origin v0.3.0The tag triggers build-and-publish, which runs only after the test, lint,
self-check and wheel-install jobs pass. It builds both distributions, checks
their metadata with twine check --strict, and uploads them using a
short-lived OIDC token that PyPI issues for this repository, workflow and
environment.
One-time setup on PyPI (Manage project → Publishing → Add a new publisher):
| Field | Value |
|---|---|
| Owner | seanwevans |
| Repository | ghast |
| Workflow | python-app.yml |
| Environment | pypi |
Until that publisher exists, the upload step fails with an authentication
error. The matching pypi environment in the repository's settings is also
where an approval requirement or a tag restriction can be added.
Contributions are welcome! Please feel free to submit a Pull Request.
Please note that this project adheres to a Code of Conduct. By participating, you are expected to uphold this code.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
- Wiz for their comprehensive GitHub Actions security guide
- The security researchers who documented GitHub Actions vulnerabilities
- The open source community for various security tools and libraries that inspired this project
This project is not affiliated with GitHub, and results produced by ghast do not guarantee complete security of your workflows.