GitHub now scans Actions logs for secrets. It won't catch the ones your agent's debug step encodes
Secret scanning of workflow logs is in public preview and off by default. The gaps in its coverage are exactly where agent-written debug steps leak, and a 30-line script covers them today.

GitHub started scanning the logs of Actions workflow runs for leaked secrets on 9 November, in public preview. It’s off by default, it only exists on Enterprise Cloud plans that have Secret Protection or Advanced Security, and its own changelog lists what it can’t see: generic patterns, custom patterns, AI-detected secrets and anything Actions has already masked. That last exclusion is the quiet one. It means the scanner and the masker share a blind spot, and a debug step that transforms a secret before printing it slips past both.
Agents write debug steps. A CI job fails, the agent can’t see why, and the next commit adds env | sort, a set -x, or an echo of the auth header it’s trying to build. This is a fair thing to do. It’s also how a token ends up in a log that every contributor to the repository can read.
What shipped
Per the changelog, GitHub scans each workflow run’s logs after the run completes, checks them against provider secret patterns, and runs validity checks for supported types. A hit appears in the repository’s secret scanning alerts, with a link back to the workflow log. The alert stays after the log is deleted, which matters because deleting the log is what the docs tell you to do first.
The limits are worth reading as a list, because each one changes how much you can lean on it:
- Enterprise owners switch it on under Policies, then Advanced Security, using the All repositories dropdown. The policy is disabled by default, and only repositories that already have secret scanning are covered.
- Only runs that finish after the policy is enabled are scanned. Existing logs aren’t backfilled.
- Alerts don’t send notifications during the preview. Nobody is paged. Somebody has to open the alert list and look.
- Detection is provider patterns only. A random 40-character deploy password, a database connection string with a custom format and anything your organisation defined itself won’t match.
For teams on the plans that have it, turning the policy on is cheap, and the alert list becomes a place to check after any agent-authored CI change. For everyone else, this is a feature to know about and not one to wait for.
The blind spot is a design choice, not a bug
Actions already redacts registered secrets from logs. The secure-use reference is explicit about where that stops: structured data can cause redaction to fail, and if a workflow transforms a secret (the docs name Base64 and URL encoding), the new value has to be registered as a secret too, or ::add-mask::VALUE has to be used on it. A value that isn’t masked and isn’t a known provider pattern is invisible to the log scanner as well.
So the leak that survives both layers has a recognisable shape. The agent builds a Basic auth header to test a registry login, prints the request to see what went wrong, and the log contains a Base64 string that decodes to user:password. Or it URL-encodes a token into a callback URL and echoes the URL. The raw token never appears anywhere. Its cousin does.
Here’s what that looks like. This script takes a log file and the secret’s value, builds the common derived forms and reports every line that contains one:
import base64, sys, urllib.parse
def forms(secret: str) -> dict[str, str]:
raw = secret.encode()
return {
"plain": secret,
"base64": base64.b64encode(raw).decode(),
"base64url": base64.urlsafe_b64encode(raw).decode().rstrip("="),
"url-encoded": urllib.parse.quote(secret, safe=""),
"hex": raw.hex(),
}
def main(log_path: str, secret: str) -> int:
log = open(log_path, encoding="utf-8", errors="replace").read()
hits = 0
for name, value in forms(secret).items():
for n, line in enumerate(log.splitlines(), 1):
if value in line:
print(f"{log_path}:{n}: {name} form of the secret is in the log")
hits += 1
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))
To see it work, here’s a synthetic job log with a made-up secret, sk_demo/9f3a+key=1. The step masks the plain value (shown as ***, the way Actions renders a registered secret) and then prints three derived forms:
Run npm test
API_KEY=***
debug auth header: Basic ZGVwbG95OnNrX2RlbW8vOWYzYStrZXk9MQ==
debug payload b64: c2tfZGVtby85ZjNhK2tleT0x
redirect=https://example.test/cb?token=sk_demo%2F9f3a%2Bkey%3D1
Run the script against it:
python3 derived_leaks.py build.log 'sk_demo/9f3a+key=1'
build.log:4: base64 form of the secret is in the log
build.log:4: base64url form of the secret is in the log
build.log:5: url-encoded form of the secret is in the log
The exit code is 1, so it fails a CI step. The plain value on line 2 is gone because it was masked, which is the system working. Lines 4 and 5 are the leak.
Line 3 is the honest limit. The Basic header is the Base64 of deploy: followed by the secret, so the bytes don’t align with the Base64 of the secret alone, and the check misses it. Catching that needs the surrounding string, which means decoding every long Base64-looking token in the log and searching the result. That’s a few more lines, and worth adding if your debug steps build auth headers. The point of the demo stands: nothing here is a provider pattern, so the new scanner wouldn’t flag any of it either.
Make the agent’s debug steps safe to commit
The fix belongs in the workflow, because that’s where the secret is still a secret. Two habits cover most of it.
Register anything derived before it’s used. If a step computes a Base64 header or an encoded token, mask the result in the same step:
- name: Build registry auth
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
AUTH=$(printf '%s' "deploy:$REGISTRY_PASSWORD" | base64 -w0)
echo "::add-mask::$AUTH"
echo "AUTH=$AUTH" >> "$GITHUB_ENV"
And keep dumps out of CI entirely. Tell the agent, in whatever context file it reads, that it may not add env, printenv, set -x or an echo of any header or URL containing credentials to a workflow, and that it should reproduce a failure locally or with the run’s own logs. That instruction works in any agent that reads a rules file, and the check that backs it is the same on all of them: a script that fails the pull request when the diff to .github/workflows/ adds one of those commands. A coarse grep is enough, as long as it runs on the diff and not on the whole file:
git diff origin/main...HEAD -- .github/workflows \
| grep -nE '^\+.*(printenv|\benv\b *(\||$)|set -x|echo .*(Authorization|token|password))' \
&& { echo "Workflow diff adds a possible secret dump"; exit 1; } || true
It will have false positives (an echo of the word “token” in a comment), and the trade is deliberate: a reviewer looks at a flagged line for ten seconds, and a log leak costs a rotation.
Rotate, don’t just delete
When a secret does reach a log, the docs’ instruction is to delete the log and rotate the secret. Order matters in practice. Deleting first removes the evidence, though the new scanner’s alert survives log deletion, which is a reason to enable it. Rotating first limits the damage. Do both, rotation first, then the delete.
The alert history is also worth reading as data. If the same workflow shows up twice, the cause is usually a debug step that’s still there, and the fix is the pull request that removes it.
Try this week
Take the last failing CI job an agent debugged, download its log, and run derived_leaks.py against each secret that job could see. If you’re on Enterprise Cloud with Secret Protection or Advanced Security and own the enterprise, turn on the Actions logs policy, then check the alert list after the next agent-authored workflow change, because during the preview nobody gets a notification.