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; }
|
|
|
|
|
|
|
|
|
|
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=(
|
|
|
|
|
'eyJ[A-Za-z0-9_-]{10,}'
|
|
|
|
|
'\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
|
|
|
|
|
|
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 line is truncated to 120 characters, which bounds how much of a long
|
|
|
|
|
# value reaches the terminal. It does NOT redact the match: a credential
|
|
|
|
|
# shorter than the budget is printed whole, and a scanner that prints the
|
|
|
|
|
# secret it found has published it to the scrollback, the CI log, and
|
|
|
|
|
# wherever that log is shipped. Masking the matched span is the real fix and
|
|
|
|
|
# is tracked separately -- until then, treat this output as sensitive.
|
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
|
|
|
printf ' %.120s…\n' "$hit"
|
|
|
|
|
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."
|