Skip to content

Commit da365de

Browse files
author
Christian Simon
committed
Guard against committing vault data
GitHub's secret scanning and push protection are free for public repos only; this is a private repo on a free-tier org, and the API refuses outright: "Secret scanning is not available for this repository" (422). So the guard is client-side, the same substitution the sibling repos make for branch protection. The specific accident worth preventing: a `bw list items` dump is every password, TOTP seed and note in plaintext, and git keeps it in history even after a later delete. .gitignore covers the known filenames passively; the pre-commit hook scans staged content actively. The scanner is deliberately narrow. Every pattern is either an exact vendor token shape or a structure with no innocent explanation -- a Bitwarden EncString, a decrypted item carrying a password, a private key block, an assigned BW_SESSION. No entropy heuristics, because those generate the false positives that train people to pass --no-verify, and a habitually bypassed hook is worse than no hook. Verified against 13 cases -- eight that must block, five that must not -- and against all 99 tracked files with no false positives.
1 parent 422baed commit da365de

6 files changed

Lines changed: 174 additions & 0 deletions

File tree

‎.config/dotnet-tools.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
"version": 1,
33
"isRoot": true,
44
"tools": {
5+
"husky": {
6+
"version": "0.9.1",
7+
"commands": [
8+
"husky"
9+
],
10+
"rollForward": false
11+
},
512
"fallout.globaltool": {
613
"version": "10.4.0",
714
"commands": [

‎.gitignore‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,22 @@ FodyWeavers.xsd
416416
*.msix
417417
*.msm
418418
*.msp
419+
420+
# ── Vault data — never commit ────────────────────────────────────────────────
421+
# A `bw list items` dump is the entire vault in PLAINTEXT: every password, TOTP
422+
# seed and note. One careless `git add -A` publishes the lot, and git history
423+
# keeps it even after a later delete. The pre-commit hook in .husky/ is the
424+
# active guard; these patterns are the passive one.
425+
items.json
426+
folders.json
427+
*vault-dump*.json
428+
*vault-export*.json
429+
bw-export*.json
430+
*.bitwarden.json
431+
REVIEW.md
432+
report.json
433+
merge-log.json
434+
435+
# Session key / master password must never reach disk in this repo
436+
.bw-session
437+
secrets.env

‎.husky/pre-commit‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env sh
2+
# Husky.NET-managed pre-commit guard. Sourcing husky.sh is optional (honors HUSKY=0
3+
# and re-execs under `sh -e`); the guard also runs standalone on a fresh clone
4+
# before `dotnet husky install` has regenerated _/.
5+
husky_sh="$(dirname -- "$0")/_/husky.sh"
6+
[ -f "$husky_sh" ] && . "$husky_sh"
7+
8+
# GitHub's secret scanning and push protection are free for public repos only, and
9+
# this is a private repo on a free-tier org. This is the substitute.
10+
sh "$(dirname -- "$0")/scan-secrets.sh"

‎.husky/scan-secrets.sh‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env sh
2+
# ─────────────────────────────────────────────────────────────────────────────
3+
# Staged-content secret scanner.
4+
#
5+
# Why this exists: GitHub's own secret scanning and push protection are not
6+
# available here — they are free for public repositories only, and this is a
7+
# private repo on a free-tier org ("Secret scanning is not available for this
8+
# repository", HTTP 422). This hook is the substitute, in the same spirit as the
9+
# pre-push guard in the sibling repositories.
10+
#
11+
# What it is guarding against specifically: this repository's whole subject is a
12+
# password vault. A `bw list items` dump is every password, TOTP seed and note in
13+
# PLAINTEXT, and git keeps it in history even after a later delete. That is the
14+
# accident worth spending a hook on.
15+
#
16+
# Design rule: NO NOISE. A hook that cries wolf is a hook everyone bypasses, and
17+
# a habitually bypassed hook is worse than none. Every pattern below is either an
18+
# exact vendor token shape or a structure that has no innocent explanation.
19+
# Entropy heuristics are deliberately absent — they are the usual source of the
20+
# false positives that kill these things.
21+
#
22+
# Bypass once (and think about why): git commit --no-verify
23+
# ─────────────────────────────────────────────────────────────────────────────
24+
set -u
25+
26+
RED='\033[0;31m'; YELLOW='\033[0;33m'; DIM='\033[2m'; OFF='\033[0m'
27+
findings=0
28+
29+
report() {
30+
findings=$((findings + 1))
31+
printf "${RED}✋ %s${OFF}\n %s\n" "$1" "$2" >&2
32+
}
33+
34+
# Staged files, added/copied/modified only — renames and deletions carry no new content.
35+
staged=$(git diff --cached --name-only --diff-filter=ACM)
36+
[ -z "$staged" ] && exit 0
37+
38+
for file in $staged; do
39+
# The scanner and its documentation necessarily contain the patterns themselves.
40+
case "$file" in
41+
.husky/scan-secrets.sh|.gitignore|docs/security*|*.md) continue ;;
42+
esac
43+
44+
# ── 1. Filenames that are vault dumps by convention ──────────────────────
45+
case "$(basename "$file")" in
46+
items.json|folders.json|report.json|merge-log.json|REVIEW.md)
47+
report "Vault dump staged: $file" \
48+
"This is vault data, not source. Remove it: git restore --staged '$file'"
49+
continue
50+
;;
51+
esac
52+
53+
# Binary files have no text to scan.
54+
git diff --cached --numstat -- "$file" | grep -q '^-' && continue
55+
56+
added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++')
57+
[ -z "$added" ] && continue
58+
59+
# ── 2. A Bitwarden EncString ─────────────────────────────────────────────
60+
# "<type>.<b64 iv>|<b64 ciphertext>|<b64 mac>" — Bitwarden's own ciphertext
61+
# format. Nothing else looks like this; its presence means vault data.
62+
if printf '%s' "$added" | grep -Eq '[0-9]\.[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}'; then
63+
report "Bitwarden EncString in $file" \
64+
"That is encrypted vault content. It does not belong in source."
65+
fi
66+
67+
# ── 3. The shape of a decrypted vault item ───────────────────────────────
68+
# bw stamps every object it emits with "object":"item"/"folder". Combined with
69+
# a password or TOTP field, this is a decrypted dump rather than a fixture.
70+
if printf '%s' "$added" | grep -Eq '"object"[[:space:]]*:[[:space:]]*"(item|folder|cipherDetails)"' \
71+
&& printf '%s' "$added" | grep -Eq '"(password|totp|privateKey)"[[:space:]]*:[[:space:]]*"[^"]{4,}'; then
72+
report "Decrypted vault item in $file" \
73+
"Looks like 'bw list items' output with real secrets. Use a redacted fixture."
74+
fi
75+
76+
# ── 4. Private keys ──────────────────────────────────────────────────────
77+
if printf '%s' "$added" | grep -Eq -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then
78+
report "Private key block in $file" "Never commit a private key."
79+
fi
80+
81+
# ── 5. Exact vendor token shapes ─────────────────────────────────────────
82+
# Each of these is a documented, unambiguous prefix+length. No guessing.
83+
if printf '%s' "$added" | grep -Eq '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}'; then
84+
report "GitHub token in $file" "Revoke it, then remove it from the change."
85+
fi
86+
if printf '%s' "$added" | grep -Eq 'AKIA[0-9A-Z]{16}'; then
87+
report "AWS access key id in $file" "Revoke it immediately."
88+
fi
89+
if printf '%s' "$added" | grep -Eq 'xox[baprs]-[A-Za-z0-9-]{10,}'; then
90+
report "Slack token in $file" "Revoke it immediately."
91+
fi
92+
if printf '%s' "$added" | grep -Eq '(sk|rk)_(live|test)_[A-Za-z0-9]{20,}'; then
93+
report "Stripe key in $file" "Revoke it immediately."
94+
fi
95+
if printf '%s' "$added" | grep -Eq 'AIza[0-9A-Za-z_-]{35}'; then
96+
report "Google API key in $file" "Revoke it immediately."
97+
fi
98+
# NuGet keys matter here: the sibling repos publish packages.
99+
if printf '%s' "$added" | grep -Eq 'oy2[a-z0-9]{43}'; then
100+
report "NuGet API key in $file" "Revoke it on nuget.org."
101+
fi
102+
103+
# ── 6. An assigned BW_SESSION ────────────────────────────────────────────
104+
# The session key decrypts the entire vault. Only flagged when it is being
105+
# given a literal value — referring to the variable is normal and fine.
106+
if printf '%s' "$added" | grep -Eq 'BW_SESSION[[:space:]]*=[[:space:]]*["'"'"']?[A-Za-z0-9+/]{40,}={0,2}'; then
107+
report "Hard-coded BW_SESSION in $file" \
108+
"That key decrypts the whole vault. Read it from the environment instead."
109+
fi
110+
111+
# ── 7. A master password assigned in code ────────────────────────────────
112+
if printf '%s' "$added" | grep -Eiq '(master_?password|masterpw)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{6,}'; then
113+
report "Hard-coded master password in $file" "Never. Prompt for it."
114+
fi
115+
done
116+
117+
if [ "$findings" -gt 0 ]; then
118+
printf "\n${YELLOW}%s finding(s). Commit blocked.${OFF}\n" "$findings" >&2
119+
printf "${DIM} Genuinely a false positive? Bypass once: git commit --no-verify${OFF}\n" >&2
120+
printf "${DIM} If a real secret already reached a commit, rotate it — deleting it later${OFF}\n" >&2
121+
printf "${DIM} does not remove it from git history.${OFF}\n" >&2
122+
exit 1
123+
fi
124+
125+
exit 0

‎.husky/task-runner.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://alirezanet.github.io/Husky.Net/schema.json",
3+
"tasks": []
4+
}

‎Directory.Build.targets‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project>
2+
<!-- Auto-install the Husky.NET git hooks (.husky) on local build so the pre-commit
3+
secret guard is always active. Skipped in CI; never fails the build.
4+
Manual equivalent: dotnet tool restore && dotnet husky install -->
5+
<Target Name="HuskyInstall" BeforeTargets="Build" Condition="'$(CI)' != 'true' And '$(HUSKY)' != '0'">
6+
<Exec Command="dotnet tool restore" WorkingDirectory="$(MSBuildThisFileDirectory)" ContinueOnError="true" StandardOutputImportance="Low" StandardErrorImportance="High" />
7+
<Exec Command="dotnet husky install" WorkingDirectory="$(MSBuildThisFileDirectory)" ContinueOnError="true" StandardOutputImportance="Low" StandardErrorImportance="High" />
8+
</Target>
9+
</Project>

0 commit comments

Comments
 (0)