DEV Community

jaryn
jaryn

Posted on

Your Leaked Prompt Secret Has a Rotation Order — Compute It Before You Click Rotate

It is 02:14. A CI job fails with a 401, a developer pastes the last sixty lines of the job log into a model chat to ask why, and the log happens to contain the runner registration token leaked by an env dump. That is the leak. Everything after it is process.

At 02:40 the on-call engineer revokes the runner token. Correct instinct, wrong scope. At 09:00 a runner registers from a host nobody recognises — not because revocation failed, but because the CI personal access token that mints runner tokens was never touched. The attacker never needed the leaked value. They needed the parent. (Reconstructed tabletop sequence, not a single reported incident.)

So here is the question I keep asking platform teams: if a secret lands in a prompt at 02:14, what does your runbook do at 02:41 — and in what order?

Most runbooks I have read answer "rotate everything" and stop there. Order is the part that decides whether containment takes four minutes or four hours of re-leaking.

1. Map where the second copy lives

A prompt is a log line with an unknown retention window. Once you accept that, the interesting question stops being "did we leak a secret?" and becomes "how many copies exist now, and which of them can mint more?"

For a hosted workspace — including a free server option — the boundary map looks like this:

Boundary Who can read the plaintext Consequence for the runbook
Browser to model endpoint you, plus a retention policy you cannot audit from here every prompt is a log line; assume it outlives the tab
Workspace container you, and whoever operates the platform no long-lived credentials belong in it
Workspace filesystem and shell history anyone who can snapshot it ~/.aws/credentials is a support ticket waiting to happen
Tool-call transcripts your own observability stack your logging pipeline is now a second copy
Repo and CI your org also the place where secrets get re-minted

Notice the last row. The minting capability usually lives on the other side of the boundary from the leak, which is exactly why rotating the leaked leaf feels like it worked.

2. Write the graph before you write the runbook

I keep the inventory as a CSV edge list, because it diffs cleanly in a PR and survives a wiki migration. Edge means "the left side can mint or replace the right side."

# minter,mintee
ci_github_pat,repo_deploy_key
ci_github_pat,ci_runner_reg_token
ci_runner_reg_token,runner_ephemeral_token
Enter fullscreen mode Exit fullscreen mode

Three edges are enough to catch the treadmill. Add one line every time you add a minting path: token-to-token, token-to-key, key-to-certificate, org-admin-to-anything.

3. Compute containment order, not just a rotation list

This is the whole artifact. Standard library only, no dependencies — I ran it against the graph above and got the output shown below.

#!/usr/bin/env python3
"""rotate_order.py - containment and restore order for a leaked credential.

Usage: python3 rotate_order.py edges.csv ci_runner_reg_token
"""
import csv
import sys
from collections import defaultdict


def load(path):
    children, parents = defaultdict(list), defaultdict(list)
    nodes = set()
    with open(path, newline='') as f:
        rows = (r for r in csv.reader(f) if r and not r[0].startswith('#'))
        for row in rows:
            parent, child = row[0].strip(), row[1].strip()
            children[parent].append(child)
            parents[child].append(parent)
            nodes.update((parent, child))
    return nodes, children, parents


def reach(start, graph):
    seen, stack = set(), [start]
    while stack:
        node = stack.pop()
        for nxt in graph.get(node, []):
            if nxt not in seen:
                seen.add(nxt)
                stack.append(nxt)
    return seen


def topo(subset, children):
    indeg = {n: 0 for n in subset}
    for n in subset:
        for c in children.get(n, []):
            if c in subset:
                indeg[c] += 1
    ready = sorted(n for n, d in indeg.items() if d == 0)
    order = []
    while ready:
        n = ready.pop(0)
        order.append(n)
        for c in sorted(children.get(n, [])):
            if c in subset:
                indeg[c] -= 1
                if indeg[c] == 0:
                    ready.append(c)
        ready.sort()
    if len(order) != len(subset):
        stuck = ', '.join(sorted(subset - set(order)))
        raise SystemExit('cycle among: ' + stuck + ' - revoke the whole cycle by hand')
    return order


def main():
    path, leaked = sys.argv[1], sys.argv[2]
    nodes, children, parents = load(path)
    if leaked not in nodes:
        raise SystemExit(leaked + ' is not in the graph')
    down = reach(leaked, children)   # what the leak can mint
    up = reach(leaked, parents)      # what can re-mint the leak
    scope = up | down | {leaked}
    contain = topo(scope, children)
    print('guard :', ', '.join(sorted(up)) or 'none')
    print('contain:', ' -> '.join(contain))
    print('restore:', ' -> '.join(reversed(contain)))


if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Running it:

$ python3 rotate_order.py edges.csv ci_runner_reg_token
guard : ci_github_pat
contain: ci_github_pat -> ci_runner_reg_token -> runner_ephemeral_token
restore: runner_ephemeral_token -> ci_runner_reg_token -> ci_github_pat
Enter fullscreen mode Exit fullscreen mode

Read that output carefully, because it says something counterintuitive. The first credential to revoke is the one that was not leaked. Kill the minter, then the leaf, then the leaf's leaf. Restore in the opposite direction so dependent jobs come back before the parent starts minting replacements.

If you rotate leaves first, you get the treadmill: revoke, re-mint, revoke, re-mint, and a very long night.

4. Handle the cycle honestly

The script hard-fails on cycles, and cycles are real — an org-admin token with permission to issue tokens can, in some configurations, mint a sibling of itself. When that happens, the honest answer is: you cannot compute order, so revoke the entire cycle manually, accept the outage, and then remove the minting loop so the next incident is computable. A graph that lies is worse than no graph.

5. Redact shapes, keep diagnostics

"Don't send secrets to a model" is advice, not a control. The control is a redactor that preserves enough shape for the model to help you.

# redact.py
import re

SHAPES = [
    ('aws_access_key_id', re.compile(r'\b(?:AKIA|ASIA)[0-9A-Z]{16}\b')),
    ('github_token', re.compile(r'\bgh[pousr]_[A-Za-z0-9]{36,255}\b')),
    ('private_key', re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----')),
    ('bearer', re.compile(r'(?i)\bauthorization:\s*bearer\s+[A-Za-z0-9._\-]{20,}')),
]


def classify(text):
    return sorted({name for name, rx in SHAPES if rx.search(text)})


def redact(text):
    for name, rx in SHAPES:
        text = rx.sub('[REDACTED:' + name + ']', text)
    return text
Enter fullscreen mode Exit fullscreen mode

The negative fixtures matter more than the positive ones. A redactor that eats git SHAs destroys debugging value and gets disabled within a week.

# test_redact.py
import pytest
from redact import classify, redact

POSITIVE = [
    'AKIA' + 'Q' * 16,
    'ghp_' + 'a1B2' * 9,
    '-----BEGIN OPENSSH PRIVATE KEY-----',
    'Authorization: Bearer abcdefghijklmnopqrstuvwx1234',
]

NEGATIVE = [
    '9f2c1e7a4b8d3c5e6f708192a3b4c5d6e7f80912',   # 40-hex git SHA
    'a3b1f2c4-9d8e-4f10-8a2b-1c2d3e4f5a6b',       # UUID
    'sha256:6b3f9c1d2e4a5b6c7d8e9f0a1b2c3d4e',     # digest label
]


@pytest.mark.parametrize('sample', POSITIVE)
def test_positive_fixtures_are_caught(sample):
    assert classify(sample), sample
    assert 'REDACTED' in redact(sample)


@pytest.mark.parametrize('sample', NEGATIVE)
def test_negative_fixtures_stay_readable(sample):
    assert classify(sample) == [], sample
Enter fullscreen mode Exit fullscreen mode

One tradeoff to state out loud: keeping a last4 for correlation is defensible for a 40-character high-entropy token and indefensible for low-entropy strings like internal hostnames. Decide per shape, not per policy paragraph.

6. Prove the revocation, don't assume it

The step everyone skips. These are command templates — I have not executed them for this article, and the expected strings vary by provider version and plan.

Credential Expected failure evidence Template
Cloud access key InvalidClientTokenId AWS_ACCESS_KEY_ID=$OLD AWS_SECRET_ACCESS_KEY=$OLD_SECRET aws sts get-caller-identity
GitHub PAT 401 Bad credentials curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $OLD" https://api.github.com/user
Deploy key Permission denied (publickey) GIT_SSH_COMMAND='ssh -i old_key -o IdentitiesOnly=yes' git ls-remote git@github.com:org/repo.git
Runner registration token 403 on re-register re-run the registration call with the old token

No evidence line, no closure. "I rotated it" is a claim; a 401 is a record.

7. Rotate now versus watch

Not every leak deserves an incident. Here is the table I actually use.

What you have evidence for Action Why
A shape matched in text you sent to a model endpoint Rotate now, containment order The copy may be retained outside your control
A secret sitting in a private workspace file, no egress Move to runtime injection, rotate at next window No evidence it crossed a boundary yet
A full private key pasted anywhere Rotate now, then audit for derived certificates Key material is not scope-limited
An internal hostname or topology detail Do not rotate; classify as topology exposure Rotation cannot fix architecture knowledge
A customer identifier Route to your privacy process, not the credential runbook Different remediation path entirely

The middle two rows are where teams burn goodwill: rotating everything on every signal trains people to ignore the alert.

8. What free access changes, and what it does not

Two availability claims are relevant here, and I am treating both as operator-supplied: MonkeyCode offers free model access and a free server option. Check the current offer terms yourself, since quotas and terms change faster than articles do.

Neither of those is a security control. They do change your threat model in one specific way: a free hosted workspace sits outside your network boundary, so your egress rules, disk encryption, and log pipeline no longer apply to the copy running there. That leads to three rules I would apply before pasting anything into it:

  1. No long-lived credentials inside the workspace — inject short-lived, scoped tokens at runtime instead.
  2. Assume the workspace filesystem and transcripts outlive your session, and export the transcript to a store you can scrub.
  3. Since trying things costs nothing, there is no budget excuse for skipping redaction. Run the redactor first, then paste.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The useful part for me is the drill, not the pricing. Free access means a small team can rehearse containment end to end before it is real.

A twenty-minute drill you can run this week

  1. Create edges.csv with three minting paths you know are true.
  2. Pick a leaf credential and run rotate_order.py against it. If it prints a guard, you just found the hole in your runbook.
  3. Add the test_redact.py negatives to CI. Four of them are enough to start.
  4. Paste one real failing log through redact() and confirm the model can still answer the question.
  5. Write the evidence line you will screenshot during an incident, per credential type.
  6. Pin the script and the graph in the repo next to the runbook, so the runbook can be regenerated rather than remembered.

Limitations and who should skip this

  • Fewer than roughly eight credentials? Skip the graph. Rotate all of them and go to bed. The graph earns its keep at scale and under partial-permission models.
  • No inventory? The script only knows edges you typed. A stale graph is false confidence; grep your CI config for credential creation before you trust the output.
  • Heavy change-approval processes? The graph becomes the ticket attachment. Run it in dry-run mode, paste the contain order, and let approval happen in parallel with the first revocation.
  • Regulated environments with per-credential incident paperwork? The tool does not replace your process. It reorders it.

The honest summary: containment is a graph problem wearing a runbook costume, and the leaked secret is almost never the first thing you should revoke.

So which invariant do you put in CI: "no secret shape leaves the runner without a redaction record," or "every mint-capable credential has a documented parent"? I would start with the second — because the first one only fails once, and the second one fails every time somebody clicks rotate in the wrong order. If you want to rehearse the drill without provisioning anything, the free tier is a reasonable place to start.

Top comments (0)