diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..58be9cb --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +if [ -n "$ROOT" ]; then + cd "$ROOT" || exit 1 +fi + +if [ "$HUSKY" = "0" ]; then + exit 0 +fi + +echo "🔐 Scanning staged changes for secrets..." +bash scripts/secret-scan-staged.sh diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..640f96b --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +if [ -n "$ROOT" ]; then + cd "$ROOT" || exit 1 +fi + +if [ "$HUSKY" = "0" ]; then + exit 0 +fi + +echo "🔐 Scanning tracked files for secrets before push..." +bash scripts/secret-scan-repo.sh diff --git a/package.json b/package.json index d72ab78..50d7334 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ "typecheck": "pnpm --filter @notelett/backend run typecheck && pnpm --filter @notelett/web run typecheck && pnpm --filter @notelett/mobile run typecheck", "test": "pnpm --filter @notelett/backend run test && pnpm --filter @notelett/web run test && pnpm --filter @notelett/mobile run test", "build": "pnpm --filter @notelett/backend run build && pnpm --filter @notelett/web run build", - "verify": "pnpm run typecheck && pnpm run test && pnpm run build" + "verify": "pnpm run typecheck && pnpm run test && pnpm run build", + "prepare": "husky" + }, + "devDependencies": { + "husky": "^9.0.0" } } diff --git a/scripts/secret-scan-repo.sh b/scripts/secret-scan-repo.sh new file mode 100755 index 0000000..7da241e --- /dev/null +++ b/scripts/secret-scan-repo.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Scans tracked files for common secret patterns. +# Intended for manual use (or as part of quick-check). Avoids printing matching lines. +# +# Note: This does not scan git history. Use a dedicated tool (e.g. gitleaks) for history scanning. + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "${ROOT}" ]]; then + exit 0 +fi + +cd "${ROOT}" + +fail=0 + +check() { + local name="$1" + local pattern="$2" + + # -l prints only filenames (no secret material in output) + if git grep -l -E "${pattern}" -- . ':!*.example' ':!*.example.*' >/dev/null 2>&1; then + echo "✗ ${name}: potential matches found in:" + git grep -l -E "${pattern}" -- . ':!*.example' ':!*.example.*' | sed 's/^/ - /' + echo + fail=1 + fi +} + +check "Private key blocks" '-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----' +check "Azure storage AccountKey leaks" 'AccountKey=[A-Za-z0-9+/=_-]{20,}' +check "Azure SharedAccessKey leaks" 'SharedAccessKey=[A-Za-z0-9+/=_-]{20,}' +check "COSMOS_KEY assignment leaks" 'COSMOS_KEY[[:space:]]*=[[:space:]]*[A-Za-z0-9+/=_-]{20,}' +check "AZURE_OPENAI_KEY assignment leaks" 'AZURE_OPENAI_KEY[[:space:]]*=[[:space:]]*[A-Za-z0-9+/=_-]{20,}' +check "AZURE_SPEECH_KEY assignment leaks" 'AZURE_SPEECH_KEY[[:space:]]*=[[:space:]]*[A-Za-z0-9+/=_-]{20,}' +check "JWT_SECRET hex-like assignments" 'JWT_SECRET[[:space:]]*=[[:space:]]*[0-9a-fA-F]{32,}' +check "OpenAI API keys (sk-...)" 'sk-[A-Za-z0-9]{20,}' +check "Stripe secret keys (sk_live_/sk_test_)" 'sk_(live|test)_[A-Za-z0-9]{20,}' +check "Stripe webhook secrets (whsec_...)" 'whsec_[A-Za-z0-9]{20,}' +check "Perplexity API keys (pplx-...)" 'pplx-[A-Za-z0-9]{20,}' +check "AWS access key ids (AKIA...)" 'AKIA[0-9A-Z]{16}' +check "Google API keys (AIza...)" 'AIza[0-9A-Za-z\-_]{35}' + +if [[ "${fail}" -ne 0 ]]; then + echo "Secret scan failed." + echo "Fix the files above (move values to Key Vault / env vars) and retry." + exit 1 +fi + +echo "✓ Secret scan passed (tracked files)" diff --git a/scripts/secret-scan-staged.sh b/scripts/secret-scan-staged.sh new file mode 100755 index 0000000..23d935e --- /dev/null +++ b/scripts/secret-scan-staged.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Blocks commits that introduce obvious secrets (Azure keys, connection strings, private keys, etc.). +# This scans only staged changes (the git index) to avoid false positives from unstaged work. +# +# Note: Avoid printing matched lines to keep secrets out of terminal scrollback/logs. + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "${ROOT}" ]]; then + exit 0 +fi + +cd "${ROOT}" + +# Nothing staged -> nothing to scan. +if git diff --cached --quiet; then + exit 0 +fi + +DIFF="$(git diff --cached --no-color --unified=0)" +if [[ -z "${DIFF}" ]]; then + exit 0 +fi + +perl -ne ' + our ($file, %hits, $exit); + + if (/^\+\+\+ b\/(.*)$/) { + $file = $1; + next; + } + + next unless defined $file; + next unless /^\+(?!\+\+\+)(.*)$/; + my $line = $1; + + my @checks = ( + ["PRIVATE_KEY_BLOCK", qr/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i], + + # Azure connection strings / keys (high signal, low false positives) + ["AZURE_STORAGE_ACCOUNT_KEY", qr/AccountKey=[A-Za-z0-9+\/=_-]{20,}/], + ["AZURE_SHARED_ACCESS_KEY", qr/SharedAccessKey=[A-Za-z0-9+\/=_-]{20,}/], + + # Common env var leaks + ["COSMOS_KEY_ASSIGNMENT", qr/\bCOSMOS_KEY\s*=\s*[A-Za-z0-9+\/=_-]{20,}/], + ["AZURE_OPENAI_KEY_ASSIGNMENT", qr/\bAZURE_OPENAI_KEY\s*=\s*[A-Za-z0-9+\/=_-]{20,}/], + ["AZURE_SPEECH_KEY_ASSIGNMENT", qr/\bAZURE_SPEECH_KEY\s*=\s*[A-Za-z0-9+\/=_-]{20,}/], + ["JWT_SECRET_HEX_ASSIGNMENT", qr/\bJWT_SECRET\s*=\s*[0-9a-fA-F]{32,}\b/], + + # OpenAI / Stripe / Perplexity + ["OPENAI_API_KEY", qr/\bOPENAI_API_KEY\s*=\s*sk-[A-Za-z0-9]{20,}/], + ["OPENAI_KEY_LIKE", qr/\bsk-[A-Za-z0-9]{20,}\b/], + ["STRIPE_SECRET_KEY", qr/\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b/], + ["STRIPE_WEBHOOK_SECRET", qr/\bwhsec_[A-Za-z0-9]{20,}\b/], + ["PERPLEXITY_API_KEY", qr/\bpplx-[A-Za-z0-9]{20,}\b/], + + # Cloud provider patterns + ["AWS_ACCESS_KEY_ID", qr/\bAKIA[0-9A-Z]{16}\b/], + ["GOOGLE_API_KEY", qr/\bAIza[0-9A-Za-z\-_]{35}\b/], + ); + + for my $check (@checks) { + my ($name, $re) = @$check; + if ($line =~ $re) { + $hits{$name}{$file} = 1; + $exit = 1; + } + } + + END { + if (!$exit) { + exit 0; + } + + print STDERR "✗ Potential secrets detected in staged changes:\n"; + for my $name (sort keys %hits) { + for my $path (sort keys %{ $hits{$name} }) { + print STDERR " - ${name}: ${path}\n"; + } + } + print STDERR "\nCommit aborted.\n"; + print STDERR "Move secrets to Azure Key Vault (or env vars injected at deploy-time), then retry.\n"; + print STDERR "If you believe this is a false positive, refactor the value into a placeholder.\n"; + exit 1; + } +' <<< "${DIFF}" +