Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXLII · CI SecretsSecrets

Masking and its limits — what the log masker catches and what it does not

Advanced⏱ ~22 mingit

What you'll learn

  • Describe the log masker pattern: literal substring match against known secret values
  • Identify the inputs that bypass the masker: multi-line secrets, URL-embedded secrets, JSON-encoded secrets, regenerated secrets
  • Distinguish the platform-controlled log stream from side channels the job opens
  • Configure the GitHub Actions ::add-mask:: directive to register a value the platform does not know about

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 log masker is the CI platform’s promise to the team: the secret value will not appear in the logs. The promise is real, but the promise’s reach is narrower than the team usually assumes. The masker catches the value as a literal substring in lines the platform sees. The masker cannot catch the secret in any form the platform does not see, in any encoding the platform does not register, or in any line the job does not send through the platform’s log sink.

How the masker works

When the forge hands a secret to the runner, it registers the plaintext as a mask pattern. When the runner forwards a log line to the platform’s log sink, the sink scans the line for any registered pattern. If a match is found, the match is replaced with *** before the line is persisted.

flowchart LR
    A["Step writes\nlog line"] --> B["Runner forwards\nto log sink"]
    B --> C["Log sink scans\nfor mask patterns"]
    C --> D{"Pattern\nmatched?"}
    D -->|yes| E["Replace with ***"]
    D -->|no| F["Persist original"]
    E --> G["Persisted log"]
    F --> G

The mechanism is intentionally simple: a literal substring match against a registry of known values. The simplicity is the source of both the matcher’s strength and its limits. A literal matcher is fast, predictable, and impossible to subvert through clever pattern tricks. A literal matcher also cannot recognise the secret in a different encoding, in a different position, or with different surrounding characters.

What the masker does not catch

Three families of inputs reliably bypass the literal-string matcher.

Multi-line secrets

A secret value that contains a newline cannot be masked by a single literal substring because the matcher operates on a single line at a time. The first line of the secret matches the registered pattern and is replaced; subsequent lines are persisted as original text. A PEM-encoded private key, a multi-line JSON Web Token, or any secret generated in a multi-line format is unmasksable as a whole.

echo "$PRIVATE_KEY"

The masker masks the variable name expansion only if the expansion produces the literal value on a single line. The newlines in the PEM break the pattern.

URL-embedded secrets

A secret embedded in a URL - as a path segment, as a query parameter, as a username in a basic-auth URL - frequently escapes the matcher because the surrounding characters alter the byte sequence the matcher compares against. A URL with a percent-encoded character, a fragment identifier, or a port number produces a string the matcher may not recognise as the registered value.

curl "https://api.example.com/v1/deploy?token=$DEPLOY_TOKEN"

If the URL is logged, the matcher sees the URL with the token interpolated; the literal $DEPLOY_TOKEN value is present. The matcher should catch this case. But the moment the URL is reconstructed by the client (percent-encoding, canonicalisation, query reordering), the byte sequence diverges from the registered value and the masker no longer matches.

JSON-encoded and Base64-encoded secrets

A secret round-tripped through jq, base64, or any serialisation layer produces a string the matcher does not recognise. The masker holds the original plaintext; the serialised form is a different byte sequence.

echo '{"token": "'$DEPLOY_TOKEN'"}' | jq

The serialised JSON contains the literal token, but the matcher compares against the registered value. If the serialiser adds whitespace, reorders keys, or escapes characters, the matcher’s literal comparison fails. The result is a logged line that contains the secret and a masker that sees nothing to mask.

Regenerated secrets

A secret whose value changes on every read (a freshly issued JWT, a nonce, a rotating token) cannot be registered before the job reads it. The masker’s registry is populated at job start; a value the job produces mid-run is not in the registry.

RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT")
echo "$RESPONSE"

The freshly-issued token in the response is a secret the masker has never seen. The masker cannot mask what the masker does not know.

The ::add-mask:: directive

GitHub Actions provides a workflow command that lets the team register an additional value with the masker at runtime. The directive is ::add-mask:: followed by the value to register.

echo "::add-mask::$FRESHLY_ISSUED_TOKEN"

The line above registers $FRESHLY_ISSUED_TOKEN with the masker; subsequent log lines that contain the value are masked. The directive is a per-line escape; the value must be on the same line as the directive, and the directive itself is consumed by the runner and not logged.

The directive is a useful tool for closing the regenerated- secret gap. It is not a substitute for not logging the secret in the first place.

The masker is one sink among many

The CI log is one of several sinks a job can write to. A job can write the same value to a file in the workspace that is uploaded as an artifact, to a database that the job connects to, to a webhook the job calls, to a child process that inherits the environment, or to any other endpoint the job opens. None of these sinks are protected by the log masker.

flowchart LR
    STEP["Step holds secret"] --> SINK1["Platform log\n(masker applies)"]
    STEP --> SINK2["Workspace file\n(no masker)"]
    STEP --> SINK3["Artifact upload\n(no masker)"]
    STEP --> SINK4["Webhook call\n(no masker)"]
    STEP --> SINK5["Child process\n(no masker)"]

The masker protects one sink. The team’s responsibility is to ensure the secret does not reach the other sinks.

Production discipline

  1. Mask a value as soon as it is produced. Register the value with ::add-mask:: in the same step that generates the value, before any log line is written.
  2. Treat the workspace as hostile to secrets. A value written to a file in the workspace is a value uploaded as an artifact unless the workflow explicitly excludes it. The default for a secret is not to write it to a file.
  3. Treat third-party logs as outside the masker. An API call that returns the secret in an error response is a third-party log that the masker does not see. Sanitise third-party responses before they reach the platform log.
  4. Do not embed secrets in URLs. A secret in a URL is a secret that may bypass the masker when the URL is reconstructed by a client library. Pass the secret in a header, not in the URL.
  5. Treat regenerated secrets as unmasksable by default. A value the job produces mid-run is not in the registry; either register it with ::add-mask:: or do not log it.

Cross-course references

  • Git, CI/CD & GitOps — Part XXXVIII-06 (Artifacts, caches, and outputs) covers the workspace-as-artifact leak path the masker does not cover.
  • Git, CI/CD & GitOps — Part XXXV-01 (The secret-leak fallacy) covers the principle that the secret is leaked the moment it is logged, regardless of whether the log is later sanitised.
  • Git, CI/CD & GitOps — Part XLI-04 (The Docker socket risk) covers the side-channel amplification pattern that lets a malicious step exfiltrate any value the runner holds, masked or not.

Quiz

Knowledge check · 4 questions

  1. Q1. Which secret-handling pattern reliably bypasses the CI log masker?

  2. Q2. A CI secret embedded in a URL's query string is reliably masked by the platform's log masker.

  3. Q3. Name the GitHub Actions directive for registering an additional value with the log masker at runtime, and explain when it must be issued.

  4. Q4. Diagnose why the masker did not protect the secret, and recommend the workflow changes that close the gap.

    Team T's deploy workflow fetches a Vault-issued database credential, runs a schema migration, and then writes the credential's connection string to the workflow log for the operator to copy. The masker does not replace the credential. The credential appears in plain text in the workflow log, in the artifact the workflow uploads, and in the error response from a third-party monitoring service that the workflow calls. A security engineer finds the credential in all three sinks a week later.

Passing score: 75%. Answers are checked in this browser.