Keeping secrets out of CI logs Jump to heading
CI systems mask secret values in logs: when a registered secret appears in output, it is replaced with asterisks. That masking is a string match, and it fails in ordinary circumstances. A secret printed base64-encoded is not the registered string. A JSON credential split over lines matches on no single line. A derived value — a token minted from a secret — was never registered at all. A set -x at the top of a script prints every variable expansion. Logs are retained for weeks, readable by everyone with repository access, and sometimes downloaded into tickets. This page lists the common escape routes and the controls for each, as part of secret scanning and remediation.
When to use this approach Jump to heading
- Your pipelines handle credentials for deploys, registries, signing or third-party APIs.
- Logs are readable by more people than the secrets themselves.
- Someone has found, or suspects, a credential in a log.
- You want to scan logs the way you scan code, alongside scanning pull requests for secrets in CI.
Step 1 — Know how masking actually works Jump to heading
Masking replaces exact occurrences of registered secret values in each log line. Anything that changes the bytes, splits them, or creates a new secret-equivalent value defeats it.
Step 2 — Register derived values as secrets too Jump to heading
When a step mints a new credential — an access token exchanged from a client secret, a short-lived registry password — register it with the masking system before anything can print it.
# GitHub Actions: mask a value created at runtime
token=$(curl -s -X POST "$AUTH_URL" -d "client_secret=$CLIENT_SECRET" | jq -r .access_token)
echo "::add-mask::$token"
echo "TOKEN=$token" >> "$GITHUB_ENV" # GitLab: variables created at runtime cannot be masked after the fact —
# write them to a file with restricted permissions instead of echoing them
script:
- umask 077; get-token > "$CI_PROJECT_DIR/.token" Order matters: the mask must be registered before the first output that could contain the value, including error messages from the command that fetched it.
Step 3 — Remove the common printers Jump to heading
A handful of habits account for most leaks. Search your pipeline definitions and scripts for them.
grep -rnE 'set -x|set -o xtrace|--verbose|-v\b.*curl|printenv|env\s*$|echo .*\$\{?[A-Z_]*(TOKEN|SECRET|KEY|PASS)' \
.github/workflows .gitlab-ci.yml ci/ scripts/ 2>/dev/null Replace each with a safer form: trace only sections that handle no secrets, print variable names rather than the environment, pass credentials in files or headers from files rather than on command lines, and redirect noisy tool output where it is not needed.
# Instead of set -x for the whole script, trace only the safe section
{ set -x; make build; set +x; } 2>&1
# Pass a token by file, not argument, so error messages cannot echo it
curl -sf -H @"$RUNNER_TEMP/auth-header" "$API/deploy" Step 4 — Keep secrets away from untrusted code paths Jump to heading
Masking assumes the code printing the log is yours. In pull requests from forks, the code is not. Workflows triggered by fork pull requests must not receive secrets at all, and workflows that deliberately run with secrets on fork code must not run that code.
# Safe default: fork PRs get no secrets
on: pull_request
# Dangerous: runs with secrets in the base repository's context
# on: pull_request_target — never check out and run the PR's code here The trigger-level details are in running CI for fork pull requests safely. A malicious contributor does not need masking to fail; they can send the secret to their own server directly if their code runs with it.
Step 5 — Scan logs after the fact, and shorten their lifetime Jump to heading
Run the same secret scanner over job logs on a schedule, and reduce log retention to what you actually use. A leak in a log that is deleted after ten days is far less serious than one kept for ninety.
# Download recent logs and scan them with the same rules used for code
for id in $(gh run list --limit 50 --json databaseId --jq '.[].databaseId'); do
gh run view "$id" --log > "logs/$id.txt" 2>/dev/null
done
gitleaks detect --no-git --source logs/ --config .gitleaks.toml --report-path log-findings.json || true
jq -r '.[] | "\(.File) \(.RuleID)"' log-findings.json Retention is a repository or organisation setting (on GitHub: Settings → Actions → General → artifact and log retention; on GitLab: the job log expiry in the CI/CD settings). Fourteen days covers most debugging needs.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
A secret appeared in a log. Is deleting the log enough? Jump to heading
No. Anyone with access could have read or downloaded it while it existed, and log processors may have copied it. Revoke and replace the credential, then delete the log to limit further exposure.
Does masking protect secrets in uploaded artefacts? Jump to heading
No. Masking applies only to the log stream. A secret written to a file that is then uploaded as an artefact or cached is not masked anywhere, which is why signing and deploy jobs should upload nothing.
Is it safe to print a secret’s length or first characters for debugging? Jump to heading
Length is generally safe. Prefixes of structured tokens are often safe and useful for identifying which token was used. Never print enough characters to make guessing the rest easier — and a secret with low entropy is weakened by any prefix.
Related Jump to heading
- Secret Scanning & Remediation — the parent topic.
- Protecting CI Signing Keys with Environment Secrets — scoping the most sensitive secret a pipeline holds.
- Responding to a Leaked Credential — what to do when a log leak is found.
- Limiting Workflow Permissions per Job — reducing what a leaked job token can do.