Git, CI/CD & GitOpsCIV · CI/CD Anti-PatternsLoggingHygiene
Secrets in logs — why masking is not enough
What you'll learn
- Identify the three ways a secret reaches the log even when the forge is configured to mask
- Apply ::add-mask proactively at the start of every job that handles a sensitive value
- Configure log redaction so secrets do not appear in plain text in the first place
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
The forge log masker is a useful safety net and a poor
primary control. It matches pre-registered strings and
replaces them with *** after the fact; it does not
stop a secret from being printed, computed, or echoed,
and it cannot recognise a secret that has been
transformed between registration and logging.
Three shapes the violation takes
- Echoing the value.
echo "$TOKEN"writes the secret to the log; if the masker knows the string, it is replaced; if not, the secret is in plain text. - Embedding in a URL. A connection string of the
form
https://user:$TOKEN@db.internal/...does not contain$TOKENas a literal string after expansion; the URL parser sees a userinfo component. - Redirecting to a file.
echo "$TOKEN" > /tmp/secretwrites the unredacted value to a file that may be uploaded as a build artifact.
flowchart LR
A["echo $TOKEN"] --> D["Log line in plain text"]
B["https://user:$TOKEN@host"] --> D
C["echo $TOKEN > /tmp/s"] --> E["File in artifact"]
E --> F["Plain-text credential in artifact"]
D --> G["Masker may or may not catch it"]
F --> G
All three produce the same outcome: a secret is exposed, and the masker is not the right tool to fix it.
The limits of the masking rule
GitHub’s mask rule matches strings registered before
the log line is printed. The matcher is exact-string
match; it does not parse the line, does not recognise
URL-encodings, and does not undo a base64
transformation. Three implications: runtime
matcher - a secret computed at job time is not
registered in advance; post-hoc - the line is
written first, then scanned, so a tee or log shipper
captures the unredacted value; no structure
parsing - a URL with an embedded credential is
logged as the URL.
What the alternative looks like
- Step scripts read inputs from the environment, not the command line.
- Connection strings use the credential helper.
git config credential.helper,aws configure sso. - Debug output goes to a file.
set +xfor sensitive blocks; redirect to/tmp/run.logonly when the run does not contain credentials. ::add-maskis called for every sensitive value the job knows about.
Production discipline
- Secrets are read from the environment, never from command-line arguments.
::add-maskis called for every sensitive value at job start.- Artifacts do not contain secrets.
Cross-course references
- This course, Part XLII (SecretVariables) - forge secret store, masking, short-lived credential ideal.
- This course, Part XLV (ArtifactImmutability) - artifacts do not contain secrets.
- This course, Part XCIV (IncidentResponse) - credential rotation after a log leak.
Quiz
Knowledge check · 4 questions
Q1. A workflow step runs `echo "$TOKEN" | base64`. The masker is configured to mask the literal value of $TOKEN. What appears in the log?
Q2. Adding `::add-mask::` at the start of a job is not sufficient to keep every secret the job handles out of the log.
Q3. Name the three ways a secret reaches the log even when the masker is configured.
Q4. Diagnose a secret leak that bypassed the masker and recommend the discipline that prevents it.
External scan finds a DB credential in public CI logs in the userinfo component of a connection string printed by a debug step. The credential has been rotated.
Passing score: 75%. Answers are checked in this browser.