What is purple team security → OWASP Top 10 mapped to cloud infrastructure → Cloud security breaches 2020–2025 → Broken access control in AWS → MFA fatigue attacks → CI/CD secrets exposure
TL;DR
- CI/CD secrets exposure is OWASP A08 + A02: credentials committed to repositories or stored in pipeline environment variables can be exfiltrated when the platform is compromised, and automated scanners find them within seconds of a public commit
- The CircleCI breach (January 2023): an engineer’s laptop was compromised via malware → session token stolen → attacker accessed CircleCI production systems → all customer environment variables (AWS keys, GitHub tokens, SSH keys) exfiltrated
- The structural problem: long-lived credentials stored in a CI/CD platform are only as secure as the platform itself — if the platform is compromised, all stored secrets are compromised
- The structural fix: OIDC workload identity replaces stored credentials with short-lived tokens issued at job runtime — there is nothing to exfiltrate
- Pre-commit hooks and CI-layer secret scanning are detection layers, not structural fixes — they catch accidents, not determined attackers
- Automated secret scanners (TruffleHog, Gitleaks) find credentials in public repos within 60–90 seconds of commit
OWASP Mapping: A08 Software and Data Integrity Failures — build pipeline integrity. A02 Cryptographic Failures — secrets stored in ways that allow exfiltration.
The Big Picture
┌─────────────────────────────────────────────────────────────────────┐
│ CI/CD SECRETS ATTACK SURFACE │
│ │
│ VECTOR 1: COMMITTED TO VCS │
│ Developer ── git commit ──▶ .env with AWS_SECRET_KEY │
│ Automated scanner ──────▶ clones within 60 seconds │
│ Attacker ───────────────▶ accesses AWS before dev notices │
│ │
│ VECTOR 2: STORED IN CI/CD PLATFORM │
│ DevOps ─── configures ──▶ AWS_ACCESS_KEY_ID in CircleCI │
│ Attacker compromises CircleCI → exfiltrates all org env vars │
│ │
│ VECTOR 3: IN CONTAINER/PROCESS ENV │
│ kubectl exec / docker inspect ──▶ printenv shows credentials │
│ Anyone with container exec access = credential access │
│ │
│ VECTOR 4: IN BUILD ARTIFACTS / LOGS │
│ Build log: "Using token: ghp_xxxxxxxxxxxx..." → exposed in log │
│ │
│ ═══════════════════════════════════════════════════════ │
│ STRUCTURAL FIX: OIDC WORKLOAD IDENTITY │
│ No stored credential → nothing to commit, nothing to exfiltrate │
│ CI job requests token at runtime → 1-hour TTL → expired │
│ │
└─────────────────────────────────────────────────────────────────────┘
CI/CD secrets exposure is not primarily a developer discipline problem — it is a structural problem. When credentials are stored in a CI/CD platform, in environment variables, or in version control, the only question is when they will be exposed, not whether. The structural answer replaces stored credentials with dynamically issued, short-lived tokens that cannot be exfiltrated because they don’t persist.
The 25-Minute Compromise: How Automated Scanning Works Against You
At 2:47 AM, a developer committed a .env file to a public GitHub repository. It contained:
DATABASE_URL=postgres://admin:prod_p@[email protected]:5432/customers
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
At 2:48 AM — 60 seconds later — an automated scanner had cloned the repository. These scanners run continuously against GitHub’s public event stream, looking for credential patterns in new commits, new files, and new repository forks.
At 3:12 AM — 25 minutes after the commit — the database started receiving unusual queries. The automated scanning infrastructure is not operated by individuals manually watching for leaks. It is fully automated: pattern match → clone → test credential validity → if valid, begin exploitation or sell.
GitHub now runs its own secret scanning and immediately invalidates some credential types (GitHub tokens, AWS IAM keys partnered with AWS) when detected in public repositories. This covers a subset of credential types. It does not cover database passwords, service-specific tokens for non-partnered services, or private repository commits that become public via fork.
The CircleCI Breach: Platform-Level Credential Exfiltration
The CircleCI breach (January 2023) is the definitive example of CI/CD platform-level secrets exposure. The attack chain:
1. CircleCI engineer's laptop compromised via malware (initial vector not fully disclosed)
2. Malware steals a 2FA-authenticated SSO session token
3. Session token valid, not expired
4. Attacker uses session token to authenticate to CircleCI internal systems
5. From internal access, attacker reaches production database
6. Production database contains encrypted customer secrets (environment variables)
7. Database also contains the encryption keys (in accessible internal system)
8. Attacker exfiltrates: encrypted secrets + encryption keys = plaintext secrets
What was stored in CircleCI environment variables by customers:
– AWS IAM access key ID and secret access key pairs
– GitHub personal access tokens and OAuth tokens
– DockerHub credentials
– SSH private keys (for deployment access)
– Heroku API keys
– Stripe, Twilio, SendGrid API keys
– Internal service account credentials
CircleCI could not determine which customer secrets were accessed and which were not — they notified all customers to rotate all credentials stored in their system.
The scale of the blast radius: Any customer who had stored long-lived credentials in CircleCI environment variables was potentially compromised. The credential was valid. The CircleCI platform’s encryption only protected against offline attacks — an attacker with internal database access and access to the key management system had everything needed to decrypt.
Red Phase: Enumerating Secrets Exposure in Your Pipeline
Scanning Repositories for Committed Secrets
# Install: pip install trufflehog3 or use the Docker image
docker run --rm \
-v "$(pwd):/repo" \
trufflesecurity/trufflehog:latest \
git file:///repo \
--json \
--only-verified \
2>/dev/null | \
jq '{
file: .SourceMetadata.Data.Git.file,
commit: .SourceMetadata.Data.Git.commit,
detector: .DetectorName,
verified: .Verified,
line: .SourceMetadata.Data.Git.line
}'
# Gitleaks: alternative scanner with SARIF output for CI integration
gitleaks detect \
--source . \
--report-format sarif \
--report-path gitleaks-report.sarif \
--verbose
# Or: scan entire git history (catches secrets that were committed then deleted)
gitleaks detect \
--source . \
--log-opts="--all" \
--report-format json \
--report-path gitleaks-history.json
# Scan a specific GitHub organization's public repositories
# (test your own org before red team exercises)
trufflehog github \
--org your-github-org \
--token "${GITHUB_TOKEN}" \
--json \
--only-verified \
2>/dev/null | \
jq '{
repo: .SourceMetadata.Data.Github.repository,
file: .SourceMetadata.Data.Github.file,
detector: .DetectorName,
verified: .Verified
}'
Enumerating Secrets in CI/CD Platform Environment Variables
# GitHub Actions: list secrets defined in a repository
# (shows names only — values are not returned by API, but names reveal what's stored)
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/your-org/your-repo/actions/secrets" | \
jq '.secrets[] | {name: .name, updated: .updated_at}'
# GitHub Actions: list organization-level secrets
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/your-org/actions/secrets" | \
jq '.secrets[] | {name: .name, visibility: .visibility, updated: .updated_at}'
# Check for credentials in running pod environment variables (Kubernetes)
# This is what an attacker with kubectl exec access would do
kubectl get pods -A -o json | \
jq -r '.items[] |
.metadata.namespace + "/" + .metadata.name + ": " +
([.spec.containers[].env[]? |
select(.name | test("KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|API"; "i")) |
.name
] | join(", "))' | \
grep -v ": $" # Only show pods with matching env var names
Testing Whether AWS Keys in CI/CD Are Over-Permissioned
# If you find an AWS access key in a scan — test its permissions
# (on your own test account's keys only)
aws sts get-caller-identity
# Returns: account, user/role ARN, caller ID
# What can this key do?
aws iam simulate-principal-policy \
--policy-source-arn $(aws sts get-caller-identity --query Arn --output text) \
--action-names "s3:*" "ec2:*" "iam:*" "sts:AssumeRole" \
--query 'EvaluationResults[?EvalDecision==`allowed`].EvalActionName' \
--output text
Blue Phase: Detection Across the Secret Lifecycle
GitHub Secret Scanning Alerts
# List secret scanning alerts in a repository via GitHub API
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/your-org/your-repo/secret-scanning/alerts?state=open" | \
jq '.[] | {
type: .secret_type,
state: .state,
created: .created_at,
url: .html_url
}'
CloudTrail: Detecting API Activity from CI/CD Credentials
When a CI/CD credential is used by an attacker, the CloudTrail events show unusual patterns:
# Find API calls from CI/CD credentials outside normal working hours
# or from unexpected IPs (attacker using the stolen key)
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=ci-deploy-user \
--start-time "$(date -d '7 days ago' --iso-8601=seconds)" \
--query 'Events[].{Time:EventTime,Name:EventName,IP:CloudTrailEvent}' \
--output json | \
jq '.[] | {
time: .Time,
event: .Name,
ip: (.IP | fromjson | .sourceIPAddress),
user_agent: (.IP | fromjson | .userAgent)
}' | \
jq 'select(.ip | test("^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)") | not)'
# Filter: events from non-RFC1918 IPs (outside your known CI/CD IP ranges)
SIEM Query: Credential Used in Multiple Regions Simultaneously
A credential being used from multiple regions simultaneously is a strong indicator of compromise:
-- Athena query against CloudTrail logs
-- Detect: same access key used from multiple regions in same hour
SELECT
userIdentity.accessKeyId,
userIdentity.userName,
COUNT(DISTINCT awsRegion) as region_count,
ARRAY_AGG(DISTINCT awsRegion) as regions,
COUNT(DISTINCT sourceIPAddress) as ip_count,
ARRAY_AGG(DISTINCT sourceIPAddress) as source_ips,
DATE_TRUNC('hour', from_iso8601_timestamp(eventTime)) as hour
FROM cloudtrail_logs
WHERE
userIdentity.type = 'IAMUser'
AND from_iso8601_timestamp(eventTime) > current_timestamp - interval '7' day
GROUP BY
userIdentity.accessKeyId,
userIdentity.userName,
DATE_TRUNC('hour', from_iso8601_timestamp(eventTime))
HAVING COUNT(DISTINCT awsRegion) > 2
ORDER BY region_count DESC;
GuardDuty: Credential Exfiltration Indicators
# GuardDuty findings relevant to CI/CD credential compromise
DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)
aws guardduty list-findings \
--detector-id "${DETECTOR_ID}" \
--finding-criteria '{
"Criterion": {
"type": {
"Equals": [
"UnauthorizedAccess:IAMUser/TorIPCaller",
"UnauthorizedAccess:IAMUser/MaliciousIPCaller",
"Discovery:IAMUser/AnomalousBehavior",
"Exfiltration:IAMUser/AnomalousBehavior",
"CredentialAccess:IAMUser/AnomalousBehavior"
]
}
}
}' \
--query 'FindingIds' --output text | \
xargs -n 10 aws guardduty get-findings \
--detector-id "${DETECTOR_ID}" \
--finding-ids | \
jq '.Findings[] | {type: .Type, user: .Resource.AccessKeyDetails.UserName, severity: .Severity}'
Purple Phase: The Structural Fix
Fix 1: OIDC Workload Identity — Eliminate Stored Credentials
This is the structural solution. Instead of storing an AWS IAM access key in your CI/CD platform, the CI/CD job authenticates to AWS using an OIDC token issued by the CI/CD provider. AWS validates the token against a pre-configured trust policy and issues temporary credentials valid for the duration of the job.
The OIDC workload identity approach eliminates static cloud access keys entirely — there is no secret to commit, no secret to exfiltrate from the CI/CD platform, and no long-lived credential to rotate on breach.
GitHub Actions with AWS OIDC — complete setup:
# .github/workflows/deploy.yml
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC token request
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
role-session-name: github-actions-${{ github.run_id }}
aws-region: us-east-1
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed
- name: Deploy
run: aws s3 sync ./dist s3://your-bucket/
AWS IAM trust policy for GitHub Actions OIDC:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
# Create the OIDC provider in AWS (one-time setup)
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list "6938fd4d98bab03faadb97b34396831e3780aea1"
# Create the IAM role with the trust policy above
aws iam create-role \
--role-name github-actions-deploy-role \
--assume-role-policy-document file://github-actions-trust-policy.json
# Attach a least-privilege policy to the role
aws iam attach-role-policy \
--role-name github-actions-deploy-role \
--policy-arn arn:aws:iam::123456789012:policy/deploy-policy
Fix 2: Pre-Commit Hooks — Catch Accidents Before They Reach VCS
Pre-commit hooks don’t stop a determined attacker. They catch accidents — the developer who forgets to move a .env file to .gitignore before staging all files.
# Install pre-commit framework
pip install pre-commit
# .pre-commit-config.yaml in your repository root
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
name: Detect hardcoded secrets
entry: gitleaks protect --staged --redact --verbose
language: golang
pass_filenames: false
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: detect-private-key
- id: check-added-large-files
args: ['--maxkb=1000']
EOF
# Install the hooks in the local repository
pre-commit install
# Test against staged files
pre-commit run --all-files
Fix 3: CI-Layer Secret Scanning — Block Before Merge
# GitHub Actions: secret scanning as a required status check
# .github/workflows/secret-scan.yml
name: Secret Scan
on:
pull_request:
types: [opened, synchronize]
jobs:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git log scanning
- name: Run TruffleHog
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
extra_args: --only-verified --json
# GitLab CI: secret detection built-in template
include:
- template: Security/Secret-Detection.gitlab-ci.yml
secret_detection:
stage: test
variables:
SECRET_DETECTION_HISTORIC_SCAN: "true" # Scan full history
Fix 4: Audit and Rotate Existing CI/CD Platform Secrets
After implementing OIDC, the migration path for existing stored credentials:
#!/bin/bash
# Purple Team EP06 — CI/CD Secrets Migration Audit
# Identifies AWS IAM keys stored in CI/CD that should be replaced with OIDC
echo "=== AWS IAM Keys Potentially Stored in CI/CD ==="
echo "--- Keys not used from expected CI/CD IPs in last 30 days ---"
# Get all IAM access keys
aws iam list-users --query 'Users[].UserName' --output text | tr '\t' '\n' | \
while read user; do
keys=$(aws iam list-access-keys --user-name "$user" \
--query 'AccessKeyMetadata[?Status==`Active`].{Key:AccessKeyId,Created:CreateDate}' \
--output json)
if [ "$(echo "$keys" | jq length)" -gt 0 ]; then
echo ""
echo "User: $user"
echo "$keys" | jq -r '.[] | " Key: " + .Key + " | Created: " + .Created'
# Check last used
echo "$keys" | jq -r '.[].Key' | while read key_id; do
last_used=$(aws iam get-access-key-last-used --access-key-id "$key_id" \
--query 'AccessKeyLastUsed.{Date:LastUsedDate,Service:ServiceName,Region:Region}' \
--output json)
echo " Last used: $(echo "$last_used" | jq -r '.Date // "Never"') | Service: $(echo "$last_used" | jq -r '.Service // "N/A"')"
done
fi
done
echo ""
echo "=== MIGRATION CHECKLIST ==="
echo " 1. For each CI/CD IAM key above:"
echo " a. Identify which CI/CD platform uses it"
echo " b. Set up OIDC trust policy for that platform"
echo " c. Update pipeline to use OIDC (no stored key)"
echo " d. Disable and then delete the IAM key"
echo " e. Verify pipelines still work"
Run This in Your Own Environment: Secrets Exposure Audit
#!/bin/bash
# Purple Team EP06 — CI/CD Secrets Exposure Audit
# Run from your workstation with git and trufflehog installed
echo "=== 1. Scan Local Repository for Committed Secrets ==="
if command -v trufflehog > /dev/null 2>&1; then
trufflehog git file://$(pwd) --only-verified --json 2>/dev/null | \
jq '{file: .SourceMetadata.Data.Git.file, detector: .DetectorName}' || \
echo " No verified secrets found in git history"
else
echo " Install trufflehog: pip install trufflehog3"
fi
echo ""
echo "=== 2. Check for .env Files in Git History ==="
git log --all --full-history -- "*.env" "**/.env" ".env.*" 2>/dev/null | \
grep "^commit" | head -5 | \
while read _ commit; do
echo " .env file committed: $commit"
git show "$commit" --stat | head -3
done
echo ""
echo "=== 3. Check Running Pods for Credential Env Vars (Kubernetes) ==="
if command -v kubectl > /dev/null 2>&1; then
kubectl get pods -A -o json 2>/dev/null | \
jq -r '.items[] |
.metadata.namespace + "/" + .metadata.name + ": " +
([.spec.containers[].env[]? |
select(.name | test("KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL"; "i")) |
.name
] | join(", "))' | \
grep -v ": $" | head -20
else
echo " kubectl not found"
fi
echo ""
echo "=== 4. GitHub Actions Secrets Inventory ==="
if [ -n "${GITHUB_TOKEN}" ]; then
REPO="your-org/your-repo" # Update this
curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/actions/secrets" | \
jq '.secrets[] | {name: .name, updated: .updated_at}'
else
echo " Set GITHUB_TOKEN to enumerate repository secrets"
fi
⚠ Common Mistakes When Addressing CI/CD Secrets Exposure
Treating secret scanning as the primary control. TruffleHog and Gitleaks catch what gets committed. They do not prevent the CircleCI attack class — an attacker who compromises the CI/CD platform itself bypasses all scanning controls. Scanning is detection; OIDC workload identity is prevention.
Rotating compromised keys without checking CloudTrail for use. When a secret is exposed, the first question is not “rotate it” — it is “was it used?” Check CloudTrail for any API activity from the key between the suspected exposure time and the rotation. If the key was used, you have an active incident, not just a credential rotation task.
Using OIDC trust policies that are too broad. The GitHub Actions OIDC trust policy in the fix section uses a StringLike condition on the sub claim to scope to a specific repository and branch. If you use StringLike: "*" instead, any GitHub Actions job in any repository can assume your role. Always scope OIDC trust policies to the specific repository, branch, and environment that needs the access.
Not scanning git history — only the working tree. Secrets that were committed and then deleted are still in git history. git rm removes the file from the working tree but not from the object store. TruffleHog and Gitleaks scan history by default when given the --all flag. Scanning only the current working tree misses all historical exposures.
Forgetting third-party GitHub Actions. The supply chain attack surface includes the Actions you reference in your workflows. An Action pinned to a mutable tag (@main, @v1) can be changed by the maintainer. Pin to a specific commit SHA and verify the Action’s provenance.
# Vulnerable: mutable tag
- uses: aws-actions/configure-aws-credentials@v4
# Secure: pinned SHA
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e831c1e4c763fe4
Quick Reference
| Secret Storage Pattern | Risk Level | Structural Fix |
|---|---|---|
| .env file committed to public repo | Critical | Pre-commit hook + OIDC |
| .env file committed to private repo | High | Git history purge + pre-commit hook + OIDC |
| Long-lived key in CI/CD env var | High | OIDC workload identity |
| Long-lived key in K8s Secret | High | Pod identity / IRSA / Workload Identity |
| Secret in build log output | Medium | Mask secrets in CI configuration |
| Secret in container env var | Medium | Vault agent / CSI secrets driver |
| Key referenced via AWS Secrets Manager | Low (if scoped) | Use for remaining static secrets |
Key Takeaways
- CI/CD secrets exposure is structural: long-lived credentials in a CI/CD platform are only as secure as that platform — the CircleCI breach proved that encryption alone is insufficient if the attacker can access the keys
- Automated secret scanners find publicly committed credentials within 60–90 seconds — rotation must happen faster than that or assume compromise
- Pre-commit hooks and CI secret scanning catch accidents; they do not prevent determined attackers who compromise the platform itself
- OIDC workload identity is the structural fix: no stored credential means no credential to exfiltrate
- When rotating a compromised key, check CloudTrail for usage between exposure and rotation before closing the incident
- OIDC trust policies must be scoped to specific repositories and branches — a wildcard trust policy recreates the exposure in a different form
- Pin third-party GitHub Actions to commit SHAs, not mutable tags — mutable tags are a supply chain attack surface
What’s Next
EP07 covers SSRF to cloud metadata: how an SSRF vulnerability in any application layer becomes a straight line to IAM credentials when IMDSv2 is not enforced. The Capital One breach anatomy — WAF SSRF → EC2 metadata → IAM role credentials → 100 million S3 records — in full technical detail, with the simulation commands and the one-line enforcement fix. If you’ve addressed identity and secrets, the network attack paths are where EP07 through EP10 focus.
Get EP07 in your inbox when it publishes → subscribe at linuxcent.com