Skip to main content
RunBook Academy

← All break/fix scenarios in Secrets, PKI & Certificates

intermediatesecret-leak~40 min

A registry token is sitting in plain text in 41 days of pipeline logs

Reported symptoms

  • At 16:40 UTC an engineer searching the internal log archive for an unrelated error string gets a hit containing what is clearly a registry push token in plain text
  • The value appears in the deploy job output of three separate pipeline runs, and once in a notification job that has never referenced the credential at all
  • Most occurrences of the same secret in the same logs are correctly replaced with asterisks, so the platform team initially argues that redaction is working and this must be a different value
  • Adding a masking directive and re-running the pipeline at 17:05 produces a clean log, and somebody proposes closing the incident on that basis
  • The original run logs are unchanged by that re-run, and the debug artefact uploaded by the failing job is still downloadable and still contains the response body
  • The registry access log shows the token used only from the expected pipeline egress addresses, which is offered as evidence that nothing was taken

Evidence

  • · A search of the log archive for the first eight characters of the token returns matches in three deploy runs and one notification run, spanning 41 days
  • · The workflow step that produced them runs a verbose HTTP request and prints the response body, and the API echoes the presented authorisation header back inside a JSON error field
  • · That occurrence is not redacted because the value appears inside a JSON string with escaped characters, and redaction relies on finding an exact match for the stored value
  • · A second step builds an authorisation header by base64 encoding the same token and prints it under shell tracing, and that encoded derivative is not redacted because it was never registered as a secret
  • · The notification job downloads the debug artefact and prints it, and it is unredacted there because the runner only redacts secrets that were used within the current job
  • · The history of the workflow file shows both debugging lines added in a single pull request 41 days ago and never removed, and the workflow has run 214 times since
  • · The debug artefact from the failing run is still present in the artefact store, inside its retention window, and contains the same response body
  • · The clean re-run at 17:05 proves only that the new directive works going forward: masking is applied as output is written and is not retroactive, so every earlier log is unchanged
Diagnosis and resolutionclick to reveal

Root cause

Automatic redaction is a best-effort filter, not a boundary, and the platform documentation says so plainly: the redaction is not guaranteed. It works by looking for the exact stored value in the output stream, and three separate properties of this pipeline defeated it. The API echoed the credential back inside a JSON error field where escaping broke the exact match. A second step derived a base64 encoding of the same token to build a header, and an encoding is a different string, so it is only redacted if it is registered separately. And the notification job printed a debug artefact produced by a different job, while the runner can only redact secrets used within the current job. Two independent defects sit underneath that. The first is a debugging habit: a pull request 41 days ago added a verbose request and a body dump to diagnose one flaky call, the change was reviewed and merged, and nobody removed it, so the pipeline printed raw upstream responses 214 times. The second, and the one that makes this an incident rather than a tidy-up, is that the credential is a long-lived static registry push token held as a repository secret. A leaked token that is valid for a single job is an event worth a log line. A leaked push token with no expiry is a supply-chain incident, because push access to the registry is the ability to publish an image that the estate will deploy without asking further questions.

Remediation

Revoke the token first, before the workflow edit, before the write-up, and above all before anyone deletes a log. Everything else in this incident is cleanup with a long tail; revocation is the single action that ends the exposure, and it works on the copies you have not found. Then issue a replacement and confirm a deployment succeeds, so the pipeline is not left broken while the investigation runs. Preserve the evidence next. Export the affected run logs and the debug artefact into the incident record before touching retention settings, because deleting the logs destroys your ability to establish the exposure window and does nothing whatsoever about the copies already in the log archive, the artefact store, the chat channel where the failure notification was posted, and any browser that has loaded the run page. Establish the window from the history of the workflow file rather than from the moment of discovery: 41 days and 214 runs, not one afternoon. Enumerate the copies deliberately and write the list down. Review the registry access log across the whole window for authentications that do not match the pipeline egress ranges, and for published image digests that do not correspond to a recorded build. Only then remove the printing steps and register any derived encoding before it is produced, treating that as defence in depth rather than as a control. Finally, replace the static credential with federated short-lived access where the registry supports it, and pin the trust policy to the audience and the subject rather than to the repository alone.

Verification

Prove the revocation from the registry, which is the party that accepts or refuses the credential, rather than from the secret store that no longer holds it. Attempt an authenticated push with the old token from a workstation and confirm it is refused. Read the registry access log and confirm no successful authentication with the old credential appears after the revocation timestamp, and that the new credential is being used by the pipeline. Search the log archive for the token again and confirm the only remaining hits are the historical runs you have deliberately quarantined for the incident record, with no new hits in any run after the change. Confirm the debug artefact has been removed from the artefact store and that a fresh pipeline run produces no artefact containing an upstream response body. Then run a canary rather than trusting the redaction: register a deliberately fabricated test value, push it through the same three paths, and confirm which of them redact it. The raw form should be starred, the encoded derivative should be starred only if you registered the derivative, and the downstream job that never used it should not be covered at all. That result is the honest map of what masking does for you. Finally, verify supply-chain integrity: every image digest deployed during the 41 day window must correspond to a build recorded in the pipeline.

Prevention

Ban printing upstream response bodies in pipeline steps. If a body is needed for debugging, write it to a file that is deleted at job end and never uploaded as an artefact, and cap debug artefact retention at seven days so a forgotten one expires on its own. Treat every derived form of a secret as a secret: base64, URL encoding, a signature, a connection string, all of them need registering before they are produced, and the directive is not retroactive so registering afterwards protects nothing already written. The durable fix is to remove the long-lived credential entirely. Use federated short-lived access so the pipeline receives a token valid only for the duration of the job, request it with the identity token permission set explicitly, and pin the trust policy to both the audience and the subject, adding the environment or the workflow reference where the deployment warrants it; pinning only the repository is not sufficient. Set a target of zero long-lived registry and cloud credentials held as pipeline secrets, reviewed quarterly, and where one is genuinely unavoidable rotate it every 90 days with a page at 100. Scan pipeline logs and artefacts for credential-shaped strings on a daily schedule with an alert within 15 minutes of a match, because the search that found this was somebody looking for something else. Finally, review which workflows use the privileged pull request trigger: an ordinary fork pull request cannot reach secrets, but that variant is granted read and write repository permissions and secret access even from a public fork.

Reported symptoms

The deploy pipeline for the internal image estate pushes to a private registry using a push token held as a repository secret. The token has no expiry. It was issued when the pipeline was written.

At 16:40 UTC a platform engineer was grepping the internal log archive for an unrelated stack trace. One of the hits was a pipeline log containing a string that was obviously the registry token.

Within twenty minutes the channel had produced four confident positions, three of which are wrong:

  • Redaction is clearly working, because the same token is starred in dozens of other places in the same logs. Therefore this must be some other value.
  • The value also appears in a notification job that has never referenced the credential in its life, which is used to argue the logs must have been tampered with.
  • Somebody adds a masking directive after the offending line, re-runs the pipeline at 17:05, sees a clean log, and proposes closing the incident.
  • Somebody else proposes deleting the affected runs, on the reasoning that the fastest way to stop a secret being in a log is for the log to stop existing.

Nobody has revoked anything. Forty minutes into the incident the token is still valid, still has push access, and is still in an artefact that anyone in the organisation can download.

Evidence provided

Read-only / Safe16:44 UTC: how far this actually goes
$ PREFIX=$(cut -c1-8 /run/incident/token-fragment)
grep -rlF "$PREFIX" /var/log/ci-archive/

The step that produced the first occurrence:

      - name: Publish manifest
        run: |
          curl -v -X POST "$API_URL/manifests" \
            -H "Authorization: Bearer $REGISTRY_TOKEN" \
            -d @manifest.json -o response.json
          cat response.json

The upstream API echoes the presented authorisation header back inside a JSON error field. That occurrence is not redacted, because the runner looks for an exact match on the stored value and the value now sits inside a JSON string with escaping around it. The documented behaviour is explicit: structured data can cause redaction to fail.

The step that produced the second occurrence:

      - name: Build auth header
        run: |
          set -x
          AUTH_B64=$(printf '%s' "$REGISTRY_TOKEN" | base64 -w0)
          echo "AUTH_B64=$AUTH_B64" >> "$GITHUB_ENV"
Read-only / Safethe change that created the exposure, and the date that sets its size
$ git log --oneline -L '/curl -v/,+4:.github/workflows/deploy.yml'

The third occurrence, in a job that never touches the credential:

  notify:
    needs: deploy
    if: failure()
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: debug-response
      - run: cat response.json
Read-only / Safethe copy that is not a log at all
$ gh run download 4821 --name debug-response --dir /tmp/incident-4821
Read-only / Safethe contrasting capture: the same secret, before and after the fix
$ gh run view 4821 --log | grep -cF "$(cut -c1-8 /run/incident/token-fragment)"

Work the evidence before reading on

The interesting thing here is not that a secret reached a log. It is that it reached the log three times by three different mechanisms, each of which the redaction feature is documented not to cover.

  1. The same token is starred in dozens of places and printed in clear in three. What does the redactor actually match on, and what happened to the value in each of the three exceptions?
  2. A job that has never referenced the credential printed it. What is the scope of the runner’s knowledge of secrets, and where did the value physically come from?
  3. The clean re-run at 17:05 looks like proof. What does it actually prove, and what property of the masking directive makes it irrelevant to run 4821?
  4. Forty minutes into the incident, list everything that has changed about the attacker’s position. Then say what would have changed it in the first sixty seconds.

Before continuing: name the first action, and be able to say why every other candidate action leaves the exposure exactly where it was.

Root cause

Redaction matches strings, and three things here are not that string

The platform is honest about this in its own documentation: automatic redaction is not guaranteed. It scans output for the stored secret values and replaces them, and every failure in this incident is a case where the thing printed was not the stored value.

  • Inside JSON. The API echoed the header back in an error field. Escaping and quoting mean the bytes on the line are not an exact match for the stored value. Structured data breaking redaction is a documented failure mode, not a bug.
  • Encoded. The base64 form is a different string. Any transformation of a secret has to be registered separately, and nobody registers a value they think of as a header rather than as a secret.
  • In another job. The runner can only redact secrets used within the current job. The notification job legitimately holds no secrets, so it has nothing to match against, and it printed an artefact produced elsewhere.

A debugging line outlived its afternoon by forty-one days

The verbose request and the body dump were added to diagnose one flaky call. That is a reasonable thing to do. What made it an exposure is that it was merged rather than run locally, and that nothing in the review or the pipeline treats printing an upstream response as a change worth a second look.

Two hundred and fourteen runs is the real number in this incident. The discovery was at 16:40 today; the exposure started 41 days ago.

The credential has no expiry, which is what turns a log line into an incident

If the pipeline had been holding a token valid for the duration of one job, this whole scenario would be a note in a retro. The token would have expired minutes after the log was written, and a copy of it would be worth nothing.

Instead the pipeline holds a static push token. Push access to the registry is the ability to publish an image that the estate will deploy, so this is not a confidentiality incident about one repository. It is a supply-chain incident with a 41 day window, and the response has to include verifying what was published during it.

Resolution

  1. Revoke the registry token. Now, before the workflow edit, before the incident document, and above all before anybody deletes a log. This is the only action that ends the exposure, and it is the only one that reaches copies you have not found yet.
  2. Issue a replacement and confirm a deployment succeeds, so the estate is not left unable to ship while the investigation runs. Prefer federated short-lived access immediately if the registry supports it, rather than minting a second static token you will have to rotate later.
  3. Preserve the evidence before touching retention. Export the affected run logs and the debug artefact into the incident record. Deleting logs destroys the exposure window, leaves every derivative copy intact, and is indistinguishable from concealment after the fact.
  4. Establish the window from the history of the workflow file, not from the discovery time. The honest number here is 41 days and 214 runs, and every estimate of blast radius depends on getting that right.
  5. Enumerate the copies and write the list down: the run logs, the artefact store, the log archive index, the chat channel that received the failure notification, and any browser that loaded the run page. Record the retention period and the owner of each.
  6. Review the registry access log across the full window for authentications from outside the pipeline egress ranges, and for published image digests that do not correspond to a recorded build. This is the step that distinguishes a disclosure from a supply-chain compromise.
  7. Only now change the pipeline: remove the body dump and the shell tracing, and register any derived encoding before it is produced. Treat that registration as defence in depth, not as the control, because it is documented as best effort and it is not retroactive.
  8. Replace the static credential with federated short-lived access, pinning the trust policy to the audience and the subject rather than to the repository alone, so that the next leak of this kind expires on its own within the job that caused it.

Verification

  1. Attempt an authenticated push with the old token from a workstation and confirm the registry refuses it. The registry is the party that accepts or refuses the credential, so it is the only witness that matters.
  2. Read the registry access log and confirm no successful authentication with the old credential appears after the revocation timestamp, and that the new credential is in use by the pipeline.
  3. Search the log archive again and confirm the only remaining matches are the historical runs you deliberately quarantined, with no new matches in any run after the change.
  4. Confirm the debug artefact is gone from the artefact store and that a fresh run produces no artefact containing an upstream response body.
  5. Run a canary instead of trusting the redactor. Register a fabricated test value, push it through all three paths, and record which are starred: the raw form, the encoded derivative, and the downstream job that never used it. That table is the honest map of what masking gives you.
  6. Verify supply-chain integrity across the window: every image digest deployed in the last 41 days must correspond to a build recorded in the pipeline. Anything unmatched is a separate and more serious incident.
  7. Confirm no workflow in the repository uses the privileged pull request trigger with a checkout of untrusted code, because that combination hands secret access to a fork.

Prevention

  • Never print an upstream response body in a pipeline step. If a body is needed to debug, write it to a file that is deleted at job end and never uploaded, and cap debug artefact retention at seven days so a forgotten one expires without anyone remembering it.
  • Treat every derivative of a secret as a secret. Base64, URL encoding, a signature, a connection string: each is a distinct string that has to be registered before it is produced. Registering afterwards protects nothing that has already been written.
  • Remove the long-lived credential. Federated short-lived access turns this class of leak into a footnote, because the token in the log expires with the job. Request it explicitly with the identity token permission and pin the trust policy to the audience and the subject, adding the environment or the workflow reference for production deployments.
  • Target zero long-lived registry and cloud credentials. Review the list quarterly. Where one is genuinely unavoidable, rotate every 90 days with a page at 100, so an undiscovered leak has a ceiling.
  • Scan logs and artefacts daily, and alert within 15 minutes. The search that found this was somebody looking for something else, on day 41. A scheduled scan would have found it on day one.
  • Review the privileged fork trigger separately. An ordinary fork pull request cannot reach secrets. The privileged variant is granted read and write repository permissions and secret access even from a public fork, and it is the single highest-value target in most pipelines.