Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCIV · CI/CD Anti-PatternsLoggingHygiene

Secrets in logs — why masking is not enough

Intermediate⏱ ~22 mingit

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

Not yet marked complete on this device.

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 $TOKEN as a literal string after expansion; the URL parser sees a userinfo component.
  • Redirecting to a file. echo "$TOKEN" > /tmp/secret writes 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 +x for sensitive blocks; redirect to /tmp/run.log only when the run does not contain credentials.
  • ::add-mask is called for every sensitive value the job knows about.

Production discipline

  1. Secrets are read from the environment, never from command-line arguments.
  2. ::add-mask is called for every sensitive value at job start.
  3. 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

  1. Q1. A workflow step runs `echo "$TOKEN" | base64`. The masker is configured to mask the literal value of $TOKEN. What appears in the log?

  2. Q2. Adding `::add-mask::` at the start of a job is not sufficient to keep every secret the job handles out of the log.

  3. Q3. Name the three ways a secret reaches the log even when the masker is configured.

  4. 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.