← Actions Secret Leak Check

This checker vs. reading the workflow by eye

Reviewing a workflow diff is the normal way a leaked secret gets caught, and it works well for the obvious case -- a step that echoes secrets.TOKEN straight into the log. GitHub's own masking already closes that one anyway. Both of the gaps this checker exists for are quieter than that, and neither looks wrong on a quick read.

What reading the diff catches

A reviewer scanning a pull request for a secrets problem is pattern-matching for the shape everyone already knows to look for: a run: line that prints a secrets.* reference directly, or passes it somewhere it obviously should not go. That pattern-match is fast, and it is right often enough that it is the standard first line of defence. It is also, by construction, blind to anything that does not match the pattern.

What this checker looks for instead

Paste your workflow YAML, or run check.py against a checkout, and it scans every step's text for two specific, well-documented patterns that do not resemble a leak on a visual scan: a value derived from a secret by base64/hex/substring before it is printed, and a value written to $GITHUB_ENV/$GITHUB_OUTPUT/$GITHUB_STATE without a matching ::add-mask::. Same rule, applied to every line, every time -- not a replacement for review, a check for the two things review is least likely to catch under time pressure.

Reading the diff by eyeThis checker
Catches echo ${{ secrets.TOKEN }} immediately -- and GitHub's own masking already catches it too, so the review effort is spent on a case that was already covered. Does not re-flag what GitHub already masks; it looks specifically for the two documented ways a secret-derived value still reaches a log unmasked.
A base64/hex/substring transform reads as ordinary string manipulation three lines from the secrets: reference, with nothing to flag it as the same secret in disguise. Recognises the transform functions GitHub itself documents as masking-defeating and flags the derived value regardless of how far it sits from the original reference.
A write to $GITHUB_OUTPUT looks like completely normal step-to-step data passing -- nothing marks a freshly derived token as something that needed its own ::add-mask:: call. Flags an export of a secret-derived value that was never registered for masking, whether or not anything downstream ever echoes it.
Depends on the reviewer already knowing both gaps exist and checking for them on every line of every workflow, every time. Applies the same two checks consistently, and can be wired into CI with --fail-on-risk so a new finding blocks the merge before a reviewer sees it.

Common questions

Why doesn't code review catch a transformed secret?

A reviewer scanning a diff is pattern-matching for the obvious shape -- echo ${{ secrets.TOKEN }} -- which GitHub's own masking already catches anyway. A base64, hex, case-fold, or substring transform three lines away from the secrets: reference does not look like a leak on a quick read; it looks like ordinary string manipulation. Recognising that the transformed output still fully reverses back to the secret requires tracing the value through the transform, which is exactly the kind of mechanical check a reviewer skimming a PR under time pressure is least likely to do.

Why doesn't code review catch an unmasked $GITHUB_OUTPUT export?

Writing a value to $GITHUB_ENV or $GITHUB_OUTPUT looks completely ordinary in a diff -- it is how a step is supposed to pass data to the next one. Nothing about the line itself signals that the value being exported was freshly derived from a secret (an OAuth token minted from it, a signed URL, a session cookie) and was therefore never registered on GitHub's masking list. A reviewer would have to know the specific rule (derived values need their own ::add-mask:: call) and trace the value back to its secret source across steps to catch it, which a visual scan does not do.

Is a human reviewer worse at this than a tool, generally?

No -- a reviewer catches things a regex scan cannot: whether the transform is even necessary, whether the workflow should be using OIDC instead of a long-lived secret at all, whether the whole step belongs in CI. What this checker adds is narrow and mechanical: two specific, well-documented masking gaps, checked the same way on every line, which is exactly the kind of check that is easy to skip when reading quickly and easy to run automatically.

Does this replace code review?

No. It is a static, step-scoped line scanner over workflow YAML text, not a security audit and not a substitute for a reviewer's judgment about whether a secret should be in the workflow at all. Wiring --fail-on-risk into CI catches the two specific patterns it knows before merge; a reviewer is still the one deciding everything else.

Nothing here says code review is a bad way to catch a secrets problem -- it is the right first line of defence for the case it is good at. This page describes the two specific, narrow cases that a visual scan is structurally unlikely to catch, not a fault in reviewers.

Check your own workflows

Paste your workflow YAML, or run check.py against a checkout, and see whether either pattern is present -- the two checks a diff review is least likely to run.

Open the Actions Secret Leak Check