echo "$SECRET" is the proximate cause; the presence of a long-lived
secret in a CI variable store is the root cause. The structural fix is
short-lived OIDC-issued credentials that are never a string that could
be echoed in the first place.
← All break/fix scenarios in Git, CI/CD & GitOps
Secret leaked to CI logs
Reported symptoms
- ●GitHub Advanced Security alerts: `Plaintext secret in workflow log` for `PROD_DEPLOY_KEY`
- ●The Actions log for run `<run-id>` shows the literal secret value (40+ characters) printed after the `echo` line
- ●The workflow file is in a public repository, so the log is publicly viewable
- ●`ACTIONS_STEP_DEBUG` is set to `true` on the run, which prints even more secrets into the log
- ●GitHub's automatic secret masking did not catch it because the value was echoed as part of a longer string with non-secret content
- ●The secret has been in the workflow log for 90 days (GitHub's retention window for public repos)
- ●No `::add-mask::` directive was used
Evidence
- · The Actions log URL contains the secret string in plain text — `curl -sL <log-url> | grep <partial-secret>` returns a hit
- · `gh run view --log <run-id> | grep -i <secret-prefix>` returns the matching line
- · The workflow file at the offending commit contains `run: echo "$PROD_DEPLOY_KEY"` or `run: env | grep PROD`
- · `gh secret list` shows `PROD_DEPLOY_KEY` is still set; the secret was not rotated after the leak
- · The workflow uses `secrets.PROD_DEPLOY_KEY` directly in `run:` (not via `env:`), which is the path that does not get masked by GitHub
- · The CI job runs with `permissions: contents: read` but uses `id-token: write` to obtain cloud credentials via OIDC — the legacy long-lived secret is not used
- · `grep -r "PROD_DEPLOY_KEY" .github/workflows/` returns one match, the `run:` line in the debug step
Diagnosis and resolutionclick to reveal
Root cause
GitHub''s automatic secret masking operates on `secrets.*` values passed through the `env:` block: it substitutes a placeholder before the step executes, so the raw value never appears in the log. When a secret is referenced directly in a `run:` shell command (`echo "${ secrets.PROD_DEPLOY_KEY }"`) or piped through a debug helper that inspects environment (`env | grep`, `printenv`), the substitution happens in the shell at execution time, after GitHub''s log capture has already started, and the raw value is printed before masking has a chance to redact it. The deeper cause is that the secret is a long-lived static credential — a long-lived bearer in a place where short-lived OIDC tokens would work. The leak is not the `echo`; the leak is the presence of a long-lived secret in a CI variable store at all.
Remediation
Rotate first, scrub second. Treat the credential as compromised the moment its value appears in any log, regardless of who could read the log or for how long. Revoke the credential at its source (the cloud console, the deploy key registration, the API token endpoint), issue a replacement, and audit downstream usage with the same urgency as a leaked production access key. Then make the leak unrepeatable: `::add-mask::$PROD_DEPLOY_KEY` at the start of the job to force masking for any value that escapes an `env:` block, and rewrite the offending step to never print the secret. The `echo` line should be deleted, not commented out — a debug step is a debug step, and the next engineer who needs to debug will reach for the same idiom.
Verification
`gh run view --log <new-run-id> | grep <partial-secret>` returns nothing on a run that exercises the same workflow path. `::add-mask::` appears at the top of every job that touches the secret. The credential has been rotated at its source and the replacement is OIDC-issued, not static. `gh secret list` and `gh secret audit` show only secrets whose access scope is justified; long-lived static deploy keys no longer appear.
Prevention
Do not put long-lived secrets in CI variable stores. Use OIDC federation: GitHub Actions OIDC for AWS/GCP/Azure, IAM Roles Anywhere for on-prem, Workload Identity for GKE. The credential is then short-lived, scoped to the job, and not present as a string that could be leaked. For the secrets that remain (registry tokens, third-party API keys with no OIDC equivalent), enforce three structural controls: lint workflows in CI to reject any `run:` line that includes `secrets.*` directly; require `::add-mask::` for any value computed at runtime that is secret-shaped; and forbid `ACTIONS_STEP_DEBUG` on public repositories entirely — debug logs include secrets that production logs do not.