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.

What masking catches and what it missesA secret echoed as-is is masked. The same secret base64-encoded, URL-encoded, split across lines, or used to derive a new token is not, because masking only matches the exact registered string on a single line.Output formMasked?echo $TOKENverbatimyesbase64 or URL-encodedtransformednomulti-line JSON keysplit over linespartlytoken minted from secretnew valuenoset -x expansionverbatim in traceusuallytreat masking as a last line of defence, not a design

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
The usual printersShell tracing prints every expanded command. Dumping the environment prints every variable. Verbose HTTP clients print authorisation headers. Error messages from tools often echo the arguments they were given, secrets included.set -xprints expansionsenv / printenvdumps everythingcurl -vprints headerstool errorsecho argumentsmost log leaks are someone debugging a pipeline and forgetting to undo it

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.

Layers that keep a secret out of a readable logUntrusted code never receives the secret. Scripts avoid printing it in any form. Masking catches verbatim slips. A scheduled log scan catches what masking missed, and short retention limits how long a missed leak stays readable.from prevention to damage limitationNo secrets for fork codenothing to leakNo printers in scriptsnothing printedMasking + add-maskverbatim slips hiddenLog scan + short retentionmisses found, then goneif a leak is found in a log, revoke the credential — deleting the log is not enough

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.