chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
#
|
|
|
|
|
# Credentials, before they are committed.
|
|
|
|
|
#
|
|
|
|
|
# ## Why this and not a generic scanner
|
|
|
|
|
#
|
|
|
|
|
# A general-purpose secret scanner knows about AWS keys and GitHub tokens. It
|
|
|
|
|
# does not know that *this* deployment issues `pllc_agent_<hex>`, or that its
|
|
|
|
|
# encrypted envelopes start `v2:<keyid>:`, or which of its environment variables
|
|
|
|
|
# hold a password. The project does know, and usually writes it down twice: once
|
|
|
|
|
# in whatever redacts its logs, and once in whatever redacts its outbound
|
|
|
|
|
# messages.
|
|
|
|
|
#
|
|
|
|
|
# So this reads the project's own patterns where they exist — point
|
|
|
|
|
# `SECRETS_PATTERN_FILE` at the module holding them — and falls back to a
|
|
|
|
|
# conservative built-in set. A scanner tuned to the shapes a project actually
|
|
|
|
|
# issues catches the leak a generic one misses, and stays quiet the rest of the
|
|
|
|
|
# time.
|
|
|
|
|
#
|
|
|
|
|
# ## What it scans
|
|
|
|
|
#
|
|
|
|
|
# By default the **staged diff**, which is the only moment a commit can still be
|
|
|
|
|
# stopped cheaply. `--tracked` scans every tracked file instead, which is what
|
|
|
|
|
# you want once, on adoption, to find what is already in the history's tip.
|
|
|
|
|
#
|
|
|
|
|
# bash scripts/secrets.sh # staged changes (use in pre-commit)
|
|
|
|
|
# bash scripts/secrets.sh --tracked # everything tracked, for an audit
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
# bash scripts/secrets.sh --built dist/ # the artifact users receive
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
# SECRETS_PATTERN_FILE=src/lib/log.ts bash scripts/secrets.sh
|
|
|
|
|
# bash scripts/secrets.sh --allow docs/examples/
|
|
|
|
|
#
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
# ## --built, and why the repository is the wrong place to stop
|
|
|
|
|
#
|
|
|
|
|
# The two modes above scan what is in git. Neither sees the bundle, which is the
|
|
|
|
|
# only artifact a user actually receives — and a key can reach it without ever
|
|
|
|
|
# being committed, from an environment variable inlined at build time. Somebody
|
|
|
|
|
# auditing applications of this kind reported finding hardcoded credentials in
|
|
|
|
|
# the frontend bundle of seven of eight in a single week.
|
|
|
|
|
#
|
|
|
|
|
# So --built walks a build directory instead, with two tiers of result:
|
|
|
|
|
#
|
|
|
|
|
# findings, which fail eyJ (a JWT header), service_role, apikey=, Bearer,
|
|
|
|
|
# plus every pattern the other modes use
|
|
|
|
|
# noted, which do not anon, VITE_, REACT_APP_, NEXT_PUBLIC_
|
|
|
|
|
#
|
|
|
|
|
# The second tier is printed and changes nothing. Those prefixes mean
|
|
|
|
|
# "deliberately shipped to the browser", so failing on them would be a
|
|
|
|
|
# permanently red gate, and a gate that is always red is one everybody has
|
|
|
|
|
# learned to ignore. But they are worth *seeing* enumerated: a Supabase anon key
|
|
|
|
|
# is safe exactly as far as row-level security makes it safe, and knowing it is
|
|
|
|
|
# out there is the input to that judgement rather than a substitute for it.
|
|
|
|
|
#
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
# ## What it cannot do
|
|
|
|
|
#
|
|
|
|
|
# It reads the working tree and the index. **A secret already committed is still
|
|
|
|
|
# in the history** after you delete it, and this will not tell you that — the
|
|
|
|
|
# fix there is a rotation, not a scan. Rotate first, then clean up.
|
|
|
|
|
#
|
|
|
|
|
# Exit codes: 0 nothing found. 1 a candidate found. 2 nothing was scanned.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
set -uo pipefail
|
|
|
|
|
|
|
|
|
|
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
|
|
|
|
printf 'secrets: not a git repository.\n' >&2
|
|
|
|
|
exit 2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
say() { printf 'secrets: %s\n' "$*" >&2; }
|
|
|
|
|
|
fix(secrets): the scanner printed the credential it found
The report truncated each line to 120 characters and redacted nothing, so a
credential shorter than the budget was printed whole -- into the terminal
scrollback, the CI log, and wherever that log is shipped. It applied to every
mode, which meant a real leak caught by the pre-commit hook was also a real leak
printed to a terminal. The comment above it claimed the match was never echoed
in full; it was corrected to describe the behaviour in the previous commit, and
this changes the behaviour instead.
The match is now masked before truncation. \001 is the substitution delimiter,
as a real control byte rather than the literal backslash-zero-zero-one a
double-quoted "\001" produces -- that first attempt made sed take `\` as its
delimiter and silently substitute nothing, which looked exactly like working
code. These patterns contain both / and |, so either would end the expression
early.
Widening the JWT pattern was part of the same fix, not a separate improvement.
Masking removes exactly what the pattern matched, so `eyJ[A-Za-z0-9_-]{10,}`
redacted the header and printed the payload and signature next to it -- and
those are the token. It now matches all three segments. A pattern that
under-matches is a pattern that half-prints the secret.
Verified in --built and --staged: a planted JWT and a user:pass@host URL are
each reported with file and line, and neither planted value appears anywhere in
the output.
closes #11
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:08:57 -05:00
|
|
|
# The delimiter for the masking substitution below. A real control byte, because
|
|
|
|
|
# these patterns contain both `/` and `|` and either would end the expression
|
|
|
|
|
# early. It cannot occur in a pattern and it cannot occur in source text.
|
|
|
|
|
MASK_D=$'\001'
|
|
|
|
|
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
MODE="staged"
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
BUILT_DIR=""
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
ALLOW=()
|
|
|
|
|
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--tracked) MODE="tracked"; shift ;;
|
|
|
|
|
--staged) MODE="staged"; shift ;;
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
--built)
|
|
|
|
|
MODE="built"
|
|
|
|
|
BUILT_DIR="${2:-}"
|
|
|
|
|
[ -n "$BUILT_DIR" ] || { say "--built needs a directory (dist/, build/, .next/…)"; exit 2; }
|
|
|
|
|
case "$BUILT_DIR" in -*) say "--built needs a directory, got '$BUILT_DIR'"; exit 2 ;; esac
|
|
|
|
|
shift 2 ;;
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
--allow) ALLOW+=("${2:-}"); shift 2 ;;
|
|
|
|
|
*) say "unknown argument: $1"; exit 2 ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
# Shapes that reach a bundle and should not. Added to PATTERNS below only in
|
|
|
|
|
# --built mode: `eyJ` is the base64 of `{"` that every JWT header starts with,
|
|
|
|
|
# and it is far too eager to run against source, where it matches ordinary
|
|
|
|
|
# base64. In a bundle it is worth the noise.
|
|
|
|
|
BUILT_PATTERNS=(
|
fix(secrets): the scanner printed the credential it found
The report truncated each line to 120 characters and redacted nothing, so a
credential shorter than the budget was printed whole -- into the terminal
scrollback, the CI log, and wherever that log is shipped. It applied to every
mode, which meant a real leak caught by the pre-commit hook was also a real leak
printed to a terminal. The comment above it claimed the match was never echoed
in full; it was corrected to describe the behaviour in the previous commit, and
this changes the behaviour instead.
The match is now masked before truncation. \001 is the substitution delimiter,
as a real control byte rather than the literal backslash-zero-zero-one a
double-quoted "\001" produces -- that first attempt made sed take `\` as its
delimiter and silently substitute nothing, which looked exactly like working
code. These patterns contain both / and |, so either would end the expression
early.
Widening the JWT pattern was part of the same fix, not a separate improvement.
Masking removes exactly what the pattern matched, so `eyJ[A-Za-z0-9_-]{10,}`
redacted the header and printed the payload and signature next to it -- and
those are the token. It now matches all three segments. A pattern that
under-matches is a pattern that half-prints the secret.
Verified in --built and --staged: a planted JWT and a user:pass@host URL are
each reported with file and line, and neither planted value appears anywhere in
the output.
closes #11
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:08:57 -05:00
|
|
|
# All three segments, not just the header. Masking removes exactly what the
|
|
|
|
|
# pattern matched, so a pattern that stops at the first dot redacts `eyJ...`
|
|
|
|
|
# and prints the payload and signature beside it -- which is the token.
|
|
|
|
|
'eyJ[A-Za-z0-9_-]{10,}(\.[A-Za-z0-9_-]+){0,2}'
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
'\bservice_role\b'
|
|
|
|
|
'\bapikey["'"'"'[:space:]]*[:=]'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Shapes that are *meant* to be public. Reported, never failed on — see the
|
|
|
|
|
# header. A finding you cannot act on is a finding that teaches people to skip
|
|
|
|
|
# the report.
|
|
|
|
|
NOTED_PATTERNS=(
|
|
|
|
|
'\bNEXT_PUBLIC_[A-Z0-9_]+'
|
|
|
|
|
'\bVITE_[A-Z0-9_]+'
|
|
|
|
|
'\bREACT_APP_[A-Z0-9_]+'
|
|
|
|
|
'\banon["'"'"'[:space:]]*[:=]'
|
|
|
|
|
)
|
|
|
|
|
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
# The built-in set. Deliberately shapes that are *structurally* credential-like
|
|
|
|
|
# rather than words that merely appear near credentials — `password` in a
|
|
|
|
|
# sentence is not a leak, and a scanner that says it is gets muted.
|
|
|
|
|
PATTERNS=(
|
|
|
|
|
'//[^/@[:space:]:]+:[^/@[:space:]]+@' # user:pass@host in a URL
|
|
|
|
|
'[?&](token|key|secret|password|access_token|api_key)=[^&[:space:]"]+'
|
|
|
|
|
'\b(Bearer|Basic)[[:space:]]+[A-Za-z0-9._~+/=-]{20,}' # an authorization header
|
|
|
|
|
# Anchored to the start of a line or an `export`, because unanchored it
|
|
|
|
|
# matched `access_token = $1` in SQL and `apiKey=` in a property list — three
|
|
|
|
|
# findings in src/ that were column names, not credentials.
|
|
|
|
|
'(^|export )[A-Z][A-Z0-9_]*(SECRET|TOKEN|PASSWORD|API_KEY|PASSWD)[A-Z0-9_]*=[^[:space:]"'"'"']{8,}'
|
|
|
|
|
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
|
|
|
|
|
'\bghp_[A-Za-z0-9]{20,}' # GitHub
|
|
|
|
|
'\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack
|
|
|
|
|
'\bAKIA[0-9A-Z]{16}\b' # AWS access key id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# The project's own shapes, if it has written them down. A `pllc_agent_<hex>`
|
|
|
|
|
# token is invisible to every generic scanner and obvious to the module that
|
|
|
|
|
# redacts it.
|
|
|
|
|
if [ -n "${SECRETS_PATTERN_FILE:-}" ] && [ -f "$SECRETS_PATTERN_FILE" ]; then
|
|
|
|
|
loaded=0
|
|
|
|
|
|
|
|
|
|
# Anchored on the closing `/flags,` and greedy to it, rather than on "no
|
|
|
|
|
# commas". The first version used `[^,]+`, which cannot cross the comma inside
|
|
|
|
|
# a bounded quantifier — so `pllc_[a-z]+_[0-9a-f]{8,}` was silently dropped
|
|
|
|
|
# along with every other `{n,}` pattern: four of six on the file this was
|
|
|
|
|
# written against, while the script printed that it had loaded them.
|
|
|
|
|
while IFS= read -r found; do
|
|
|
|
|
[ -n "$found" ] || continue
|
|
|
|
|
|
|
|
|
|
PATTERNS+=("$found")
|
|
|
|
|
loaded=$((loaded + 1))
|
|
|
|
|
done < <(
|
|
|
|
|
sed -nE 's/.*\[\/(.+)\/[gimsuy]*,[[:space:]]*".*/\1/p' "$SECRETS_PATTERN_FILE" 2>/dev/null || true
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# The count, never a bare reassurance. "Loaded project patterns" over an empty
|
|
|
|
|
# list is the same lie as a green test run that executed nothing.
|
|
|
|
|
if [ "$loaded" -gt 0 ]; then
|
|
|
|
|
say "loaded $loaded project pattern(s) from $SECRETS_PATTERN_FILE"
|
|
|
|
|
else
|
|
|
|
|
say "WARNING: $SECRETS_PATTERN_FILE yielded no patterns — scanning with the"
|
|
|
|
|
say " built-in set only. Check the file holds regex literals."
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
if [ "$MODE" = "built" ]; then
|
|
|
|
|
[ -d "$BUILT_DIR" ] || { say "no such directory: $BUILT_DIR"; say "Nothing was scanned, which is not a pass."; exit 2; }
|
|
|
|
|
|
|
|
|
|
CONTENT=""
|
|
|
|
|
WHAT="the built output in $BUILT_DIR"
|
|
|
|
|
PATTERNS+=("${BUILT_PATTERNS[@]}")
|
|
|
|
|
|
|
|
|
|
while IFS= read -r file; do
|
|
|
|
|
skip=""
|
|
|
|
|
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
|
|
|
|
|
case "$file" in *"$allowed"*) skip="yes" ;; esac
|
|
|
|
|
done
|
|
|
|
|
[ -n "$skip" ] && continue
|
|
|
|
|
|
|
|
|
|
# Source maps are the build's own copy of the source and would double every
|
|
|
|
|
# finding; they are worth scanning on purpose, not by accident.
|
|
|
|
|
case "$file" in *.map) continue ;; esac
|
|
|
|
|
|
|
|
|
|
file "$file" 2>/dev/null | grep -q "text" || continue
|
|
|
|
|
|
|
|
|
|
# Relative to the build directory, not the absolute path find produced.
|
|
|
|
|
# The report truncates each line to keep a secret off the terminal, and an
|
|
|
|
|
# absolute path in a temp directory can consume that budget entirely --
|
|
|
|
|
# leaving a finding that names a file and shows nothing about the match.
|
|
|
|
|
rel="${file#"$BUILT_DIR"/}"
|
|
|
|
|
CONTENT+="$(sed "s|^|${rel}: |" "$file")"$'\n'
|
|
|
|
|
done < <(find "$BUILT_DIR" -type f -size -20M 2>/dev/null)
|
|
|
|
|
|
|
|
|
|
elif [ "$MODE" = "staged" ]; then
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
# Added lines only. A removed line containing a token is somebody deleting
|
|
|
|
|
# one, which is the opposite of a leak.
|
|
|
|
|
CONTENT="$(git diff --cached --unified=0 --no-color | grep '^+' | grep -v '^+++' || true)"
|
|
|
|
|
WHAT="staged changes"
|
|
|
|
|
else
|
|
|
|
|
CONTENT=""
|
|
|
|
|
WHAT="tracked files"
|
|
|
|
|
|
|
|
|
|
while IFS= read -r file; do
|
|
|
|
|
skip=""
|
|
|
|
|
|
|
|
|
|
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
|
|
|
|
|
case "$file" in *"$allowed"*) skip="yes" ;; esac
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
[ -n "$skip" ] && continue
|
|
|
|
|
[ -f "$file" ] || continue
|
|
|
|
|
|
|
|
|
|
# Text only; a webp full of bytes will match anything.
|
|
|
|
|
file "$file" 2>/dev/null | grep -q "text" || continue
|
|
|
|
|
|
|
|
|
|
CONTENT+="$(sed "s|^|${file}: |" "$file")"$'\n'
|
|
|
|
|
done < <(git ls-files)
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -z "$CONTENT" ]; then
|
|
|
|
|
say "nothing to scan in $WHAT."
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
found=0
|
|
|
|
|
|
|
|
|
|
for pattern in "${PATTERNS[@]}"; do
|
|
|
|
|
while IFS= read -r hit; do
|
|
|
|
|
[ -n "$hit" ] || continue
|
|
|
|
|
|
|
|
|
|
skip=""
|
|
|
|
|
for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do
|
|
|
|
|
case "$hit" in *"$allowed"*) skip="yes" ;; esac
|
|
|
|
|
done
|
|
|
|
|
[ -n "$skip" ] && continue
|
|
|
|
|
|
fix(secrets): the scanner printed the credential it found
The report truncated each line to 120 characters and redacted nothing, so a
credential shorter than the budget was printed whole -- into the terminal
scrollback, the CI log, and wherever that log is shipped. It applied to every
mode, which meant a real leak caught by the pre-commit hook was also a real leak
printed to a terminal. The comment above it claimed the match was never echoed
in full; it was corrected to describe the behaviour in the previous commit, and
this changes the behaviour instead.
The match is now masked before truncation. \001 is the substitution delimiter,
as a real control byte rather than the literal backslash-zero-zero-one a
double-quoted "\001" produces -- that first attempt made sed take `\` as its
delimiter and silently substitute nothing, which looked exactly like working
code. These patterns contain both / and |, so either would end the expression
early.
Widening the JWT pattern was part of the same fix, not a separate improvement.
Masking removes exactly what the pattern matched, so `eyJ[A-Za-z0-9_-]{10,}`
redacted the header and printed the payload and signature next to it -- and
those are the token. It now matches all three segments. A pattern that
under-matches is a pattern that half-prints the secret.
Verified in --built and --staged: a planted JWT and a user:pass@host URL are
each reported with file and line, and neither planted value appears anywhere in
the output.
closes #11
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:08:57 -05:00
|
|
|
# The match is masked, then the line is truncated. Truncation alone was not
|
|
|
|
|
# enough and used to be all there was: it bounds how much of a LONG value
|
|
|
|
|
# reaches the terminal and prints a short one whole, so the scanner
|
|
|
|
|
# published the very thing it was built to find — to the scrollback, the CI
|
|
|
|
|
# log, and wherever that log is shipped.
|
|
|
|
|
#
|
|
|
|
|
# \001 as the delimiter, because these patterns contain both `/` and `|`
|
|
|
|
|
# and either would end the expression early. It cannot occur in a pattern
|
|
|
|
|
# and it cannot occur in the text of a source file.
|
|
|
|
|
masked="$(printf '%s' "$hit" | sed -E "s${MASK_D}${pattern}${MASK_D}[redacted]${MASK_D}g" 2>/dev/null)"
|
|
|
|
|
[ -n "$masked" ] || masked="[a line matching a credential pattern, unprintable]"
|
|
|
|
|
printf ' %.120s…\n' "$masked"
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
found=$((found + 1))
|
|
|
|
|
done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20)
|
|
|
|
|
done
|
|
|
|
|
|
feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.
Two tiers, because one would have been useless:
findings (exit 1) eyJ, service_role, apikey=, plus every pattern the other
modes already use
noted (exit 0) NEXT_PUBLIC_, VITE_, REACT_APP_, anon
The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.
eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.
Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.
One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.
closes #8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
|
|
|
# The public-by-design tier. Printed, counted, and deliberately not fatal.
|
|
|
|
|
if [ "$MODE" = "built" ]; then
|
|
|
|
|
noted=0
|
|
|
|
|
|
|
|
|
|
for pattern in "${NOTED_PATTERNS[@]}"; do
|
|
|
|
|
while IFS= read -r hit; do
|
|
|
|
|
[ -n "$hit" ] || continue
|
|
|
|
|
if [ "$noted" -eq 0 ]; then
|
|
|
|
|
say "shipped to the browser on purpose — check each is meant to be public:"
|
|
|
|
|
fi
|
|
|
|
|
printf ' %.120s…\n' "$hit"
|
|
|
|
|
noted=$((noted + 1))
|
|
|
|
|
done < <(printf '%s\n' "$CONTENT" | grep -oEI "$pattern" 2>/dev/null | sort -u | head -20)
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
if [ "$noted" -gt 0 ]; then
|
|
|
|
|
say "$noted public reference(s) above. Not a failure: those prefixes mean"
|
|
|
|
|
say "the value was compiled in deliberately. A Supabase anon key is safe"
|
|
|
|
|
say "exactly as far as row-level security makes it safe — this is the input"
|
|
|
|
|
say "to that judgement, not a substitute for it."
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00
|
|
|
if [ "$found" -gt 0 ]; then
|
|
|
|
|
say "$found candidate credential(s) in $WHAT."
|
|
|
|
|
say "If one is real: rotate it first. Deleting the line does not remove it"
|
|
|
|
|
say "from a commit that already exists, and the scan cannot see history."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
say "no credential shapes in $WHAT."
|