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 eye | This 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.