diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..bfcb8ae --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,31 @@ +name: Secret Scan +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] +jobs: + secret-scan: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # Shallow, single-branch checkout: only the tip commit of the branch + # under test, no commit history. The scan is a filesystem scan of the + # working tree, so history would be fetched bandwidth for nothing. + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install trufflehog + run: | + curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \ + | sh -s -- -b /usr/local/bin + trufflehog --version + + # Runs the same script as the local husky hooks, so CI and the developer + # machine cannot drift apart. Scans the whole checked-out filesystem + # rather than the PR diff: a secret already present on the branch is a + # leak whether or not this PR is the commit that introduced it. + - name: Scan filesystem for secrets + run: sh scripts/trufflehog-scan.sh ci diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..d550123 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +sh scripts/trufflehog-scan.sh pre-commit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..9c84a4a --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +sh scripts/trufflehog-scan.sh pre-push diff --git a/package-lock.json b/package-lock.json index ce7a1fc..55a271b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^10.17.5", "@types/react": "^16.9.11", "css-loader": "^6.5.1", + "husky": "^9.1.7", "source-map-loader": "^0.2.4", "style-loader": "^1.2.1", "ts-loader": "^9.2.6", @@ -3067,15 +3068,6 @@ "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==", "license": "MIT" }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -3178,18 +3170,6 @@ "multicast-dns": "^7.2.5" } }, - "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -5372,6 +5352,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -6272,6 +6268,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", diff --git a/package.json b/package.json index bcb7ee0..2bb82ed 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "scripts": { "build": "webpack --config webpack.prod.js", "start": "webpack serve --config webpack.dev.js", - "dev": "webpack --config webpack.dev.js" + "dev": "webpack --config webpack.dev.js", + "prepare": "husky" }, "repository": { "type": "git", @@ -22,6 +23,7 @@ "@types/node": "^10.17.5", "@types/react": "^16.9.11", "css-loader": "^6.5.1", + "husky": "^9.1.7", "source-map-loader": "^0.2.4", "style-loader": "^1.2.1", "ts-loader": "^9.2.6", diff --git a/scripts/trufflehog-exclude.txt b/scripts/trufflehog-exclude.txt new file mode 100644 index 0000000..3e31881 --- /dev/null +++ b/scripts/trufflehog-exclude.txt @@ -0,0 +1,7 @@ +(^|/)node_modules/ +(^|/)coverage/ +(^|/)\.git/ +(^|/)dist/ +(^|/)build/ +(^|/)package-lock\.json$ +(^|/)\.npmrc$ diff --git a/scripts/trufflehog-scan.sh b/scripts/trufflehog-scan.sh new file mode 100755 index 0000000..4080833 --- /dev/null +++ b/scripts/trufflehog-scan.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Secret scan with trufflehog, used by the husky pre-commit and pre-push hooks. +# +# Usage: scripts/trufflehog-scan.sh [hook-name] +# +# Scans the CURRENT FILESYSTEM only — never git history. The scanned set is +# tracked + untracked files that are not gitignored, mirrored into a temp tree +# so gitignored local files (e.g. an .npmrc auth token) cannot fail the hook. +# Paths in scripts/trufflehog-exclude.txt are skipped. +set -e + +HOOK="${1:-trufflehog}" +PAD=$(printf '%*s' ${#HOOK} '') + +# GUI git clients (VSCode Source Control, GitHub Desktop, Tower, Fork...) often +# launch git from a process tree rooted at Finder, which never sourced the +# user's login shell profile. Homebrew's bin dir is therefore missing from PATH +# and trufflehog looks uninstalled even when it is not. Add the standard +# Homebrew locations for Apple Silicon and Intel before looking it up. +PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" +export PATH + +if ! command -v trufflehog >/dev/null 2>&1; then + echo "$HOOK: trufflehog not found in PATH — cannot verify there are no secrets." + echo "$PAD install it with: brew install trufflehog" + echo "$PAD aborting: an unverifiable tree is treated as a failure, not a pass." + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) + +TMPDIR_SCAN=$(mktemp -d) +trap 'rm -rf "$TMPDIR_SCAN"' EXIT + +# Build the file list first so a git failure aborts instead of silently +# producing an empty (and therefore trivially "clean") scan set. +FILE_LIST="$TMPDIR_SCAN.files" +if ! git -C "$REPO_ROOT" ls-files --cached --others --exclude-standard -z > "$FILE_LIST"; then + echo "$HOOK: failed to list repository files. Aborting." + rm -f "$FILE_LIST" + exit 1 +fi + +# rsync copies the NUL-delimited list in one pass and creates parent dirs for +# us. Deliberately not a `read -d ''` loop: that is a bashism, and this script +# runs under /bin/sh, which is dash on CI runners. +if ! command -v rsync >/dev/null 2>&1; then + echo "$HOOK: rsync not found in PATH — cannot assemble the scan set. Aborting." + rm -f "$FILE_LIST" + exit 1 +fi + +if ! rsync -a --files-from="$FILE_LIST" --from0 "$REPO_ROOT/" "$TMPDIR_SCAN/"; then + echo "$HOOK: failed to copy files for scanning. Aborting." + rm -f "$FILE_LIST" + exit 1 +fi +rm -f "$FILE_LIST" + +# trufflehog exits 0 on a path it cannot read, so an empty scan set would look +# clean. Refuse to pass in that case. +if [ -z "$(find "$TMPDIR_SCAN" -type f -print -quit)" ]; then + echo "$HOOK: no files were collected to scan. Aborting rather than passing." + exit 1 +fi + +echo "$HOOK: scanning current filesystem for secrets..." +set +e +trufflehog filesystem "$TMPDIR_SCAN" \ + --exclude-paths "$REPO_ROOT/scripts/trufflehog-exclude.txt" \ + --results=verified,unknown,unverified \ + --no-update --fail +SCAN_STATUS=$? +set -e + +# 183 is trufflehog's --fail code for "secrets found"; anything else non-zero +# means the scan itself broke. Both block: never pass on an unverified tree. +if [ "$SCAN_STATUS" = "183" ]; then + echo "" + echo "$HOOK: potential secrets found. Aborted." + echo "$PAD remove them, or re-run with --no-verify if this is a false positive." + exit 1 +elif [ "$SCAN_STATUS" != "0" ]; then + echo "" + echo "$HOOK: trufflehog exited with status $SCAN_STATUS (scan failed). Aborted." + echo "$PAD the tree could not be verified, so this is treated as a failure." + exit 1 +fi + +echo "$HOOK: no secrets found."