Masked in the log, stolen anyway
CI 'masks' secrets in logs. A one-line change in a third-party action, or a base64 pipe, prints them in the clear to anyone who can read build logs.
- Weakness
- CWE-532 · Insertion of sensitive information into log file · CICD-SEC-6
- Target
- a GitHub Actions workflow in
sec-lab
permissions:zizmorRun these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.
The threat
01The workflow under attack
It looks harmless: a checkout, a popular third-party action pinned to a moving tag, and a deploy step using a long-lived token secret.
.github/workflows/deploy.ymlwhole fileyaml on: push jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: some-org/changed-files@v45 # moving tag - run: ./deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}02Bypass masking with an encoding
GitHub replaces EXACT secret values in log output with
***. Any transformation (base64, reversing, inserting spaces) produces a string the masker doesn't know. A malicious action, a compromised dependency, or an insider's 'debug' line can print it. Real incident: in March 2025 the widely usedtj-actions/changed-filesaction was compromised, and its tags were re-pointed to code that dumped runner memory secrets into public build logs of thousands of repositories.terminal$ echo "$DEPLOY_TOKEN" # in a run stepecho "$DEPLOY_TOKEN" | base64 # same step── output ──***Z2hwX2R4N0t2UDJtUTlMcjRUeDhXYjNOYzZWZDFIag==03Decode offline
Anyone with read access to the logs (on a public repo, the whole internet) reverses it.
terminal$ echo Z2hwX2R4N0t2UDJtUTlMcjRUeDhXYjNOYzZWZDFIag== | base64 -d── output ──ghp_dx7KvP2mQ9Lr4Tx8Wb3Nc6Vd1Hj
What's at risk
- The deploy token, with whatever it can do: push code, publish releases, deploy to production.
- Because the action was referenced by tag, every repo using it was affected the moment the tag moved, with no change on their side.
Detect
01Static analysis for workflows: zizmor
zizmor audits GitHub Actions workflows for security problems: unpinned actions, excessive default permissions, template injection, dangerous triggers (
pull_request_target, covered in Chapter 3), and credentials persisted by checkout.terminal$ pipx run zizmor .github/workflows/── output ──warning[unpinned-uses]: unpinned action reference--> .github/workflows/deploy.yml:7:9|7 | - uses: some-org/changed-files@v45| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ action is not pinned to a hashwarning[excessive-permissions]: overly broad permissions--> .github/workflows/deploy.yml:3:3|3 | deploy:| ^^^^^^ default permissions used due to no permissions: blockwarning[artipacked]: credential persistence through GitHub Actions artifacts--> .github/workflows/deploy.yml:6:9|6 | - uses: actions/checkout@v4| ^^^^^^^^^^^^^^^^^^^^^^^^^ does not set persist-credentials: false3 findings: 0 unknown, 0 informational, 0 low, 3 medium, 0 high02Watch for secrets that DO reach logs
Run gitleaks or trufflehog over downloaded CI logs periodically; they recognise encoded formats of common tokens. GitHub's secret scanning also covers some public logs. Treat any finding as a leak (Lab 0.1's playbook).
Defend
01Pin, restrict, and stop using long-lived tokens
Pin third-party actions to a full commit SHA (a tag can be re-pointed; a SHA can't), with the version in a comment so Dependabot can update it. Set
permissions:to the minimum; the default token can write to the repo. Don't persist the checkout credential. Replace the static deploy token with OIDC (short-lived, scoped to this repo and environment), so there's no long-lived secret to steal.Vulnerable
.github/workflows/deploy.ymlwhole fileyaml jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: some-org/changed-files@v45 - run: ./deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Hardened
.github/workflows/deploy.ymlwhole fileyaml permissions: {} # nothing by default jobs: deploy: runs-on: ubuntu-latest environment: prod # approval + env-scoped credentials permissions: contents: read id-token: write # OIDC only steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: { persist-credentials: false } - uses: some-org/changed-files@<full-40-char-sha> # v45.0.8 - uses: aws-actions/configure-aws-credentials@<sha> # v4 with: role-to-assume: arn:aws:iam::123456789012:role/sec-lab-deployer aws-region: ap-south-1 - run: ./deploy.sh02Keep pins updated automatically
SHA pins would go stale without automation. Dependabot understands
@<sha> # vX.Y.Zand opens PRs that bump both.Hardened
.github/dependabot.ymlwhole fileyaml version: 2 updates: - package-ecosystem: github-actions directory: / schedule: { interval: weekly }
Verify
01zizmor is clean; there's no token to print
Even if the base64 trick ran now, there's no
DEPLOY_TOKENin the environment to print, and the AWS credentials from OIDC expire within an hour and only work for this repo's prod environment.terminal$ pipx run zizmor .github/workflows/── output ──No findings to report. Good job!
The concepts
Masking is a convenience, not a control
Log masking prevents ACCIDENTAL exact prints. It can't stop deliberate exfiltration: encoding, splitting, writing to a file uploaded as an artifact, or sending the value over the network to an attacker. Any code running in a job that has a secret can take the secret. The real controls are: fewer secrets (OIDC), less-trusted code never running with them, and short-lived, narrowly scoped credentials.
Third-party actions are dependencies with your secrets
uses: org/action@v4 runs someone else's code, fetched at run time, inside a job that may hold your credentials. A moving tag means that code can change without any change in your repo. Pin to SHAs, prefer actions from verified publishers or your own org, review what they do, and give the job only the permissions that step needs.
Your turn
Look up the commit SHA for actions/checkout@v4 and write the pinned uses: line.
Which permissions: does a job need to post a comment on a pull request, and nothing else?
Interview questions
How do you secure secrets in a CI/CD pipeline?
Why pin GitHub Actions to a commit SHA instead of a version tag?