diff --git a/.husky/pre-commit b/.husky/pre-commit index 105575b6..a6bbb5fd 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,13 +1,20 @@ . "$HOME/.nvm/nvm.sh" 2>/dev/null -# 1. Svenska tecken-check (snabb, ~0.5s) +# 1. Secret-scan på staged innehåll (snabb, ~0.1s) +echo "Söker efter läckta hemligheter..." +bash scripts/check-no-secrets.sh || { + echo "Hemlighet hittad i staged innehåll! Flytta värdet till .env." + exit 1 +} + +# 2. Svenska tecken-check (snabb, ~0.5s) echo "Kontrollerar svenska tecken..." npm run check:swedish --silent || { echo "Svenska tecken-problem hittade! Fix innan commit." exit 1 } -# 2. TypeScript-check BARA om .ts/.tsx-filer är staged +# 3. TypeScript-check BARA om .ts/.tsx-filer är staged STAGED=$(git diff --cached --name-only --diff-filter=d | grep -E '\.(ts|tsx)$' || true) if [ -n "$STAGED" ]; then echo "TypeScript-check (staged .ts/.tsx hittade)..." @@ -17,5 +24,5 @@ if [ -n "$STAGED" ]; then } fi -# 3. Branch-check (BLOCKER om kod-commit på main under aktiv story) +# 4. Branch-check (BLOCKER om kod-commit på main under aktiv story) bash scripts/check-branch-for-story.sh || exit 1 diff --git a/prisma/seed-guard.test.ts b/prisma/seed-guard.test.ts new file mode 100644 index 00000000..f220e94b --- /dev/null +++ b/prisma/seed-guard.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest" +import { assertSeedSafe } from "./seed-guard" + +describe("assertSeedSafe", () => { + it("allows local Supabase URL (127.0.0.1)", () => { + expect(() => + assertSeedSafe({ supabaseUrl: "http://127.0.0.1:54321", allowProd: false }) + ).not.toThrow() + }) + + it("allows local Supabase URL (localhost)", () => { + expect(() => + assertSeedSafe({ supabaseUrl: "http://localhost:54321", allowProd: false }) + ).not.toThrow() + }) + + it("rejects hosted Supabase URL by default", () => { + expect(() => + assertSeedSafe({ + supabaseUrl: "https://xybyzflfxnqqyxnvjklv.supabase.co", + allowProd: false, + }) + ).toThrow(/refusing to seed against hosted Supabase/i) + }) + + it("rejects Supabase pooler URL by default", () => { + expect(() => + assertSeedSafe({ + supabaseUrl: "https://pooler.supabase.com:5432", + allowProd: false, + }) + ).toThrow(/refusing to seed/i) + }) + + it("allows hosted URL when ALLOW_SEED_PROD is true", () => { + expect(() => + assertSeedSafe({ + supabaseUrl: "https://zzdamokfeenencuggjjp.supabase.co", + allowProd: true, + }) + ).not.toThrow() + }) + + it("includes the target URL in the error message", () => { + expect(() => + assertSeedSafe({ + supabaseUrl: "https://xybyzflfxnqqyxnvjklv.supabase.co", + allowProd: false, + }) + ).toThrow(/xybyzflfxnqqyxnvjklv\.supabase\.co/) + }) +}) diff --git a/prisma/seed-guard.ts b/prisma/seed-guard.ts new file mode 100644 index 00000000..1f7f611d --- /dev/null +++ b/prisma/seed-guard.ts @@ -0,0 +1,28 @@ +/** + * Guard against accidentally seeding a hosted (staging/production) Supabase + * project. The seed script creates users with a hardcoded test password — + * running it against prod would set real accounts to that password. + * + * Override with ALLOW_SEED_PROD=true if you genuinely need to seed a hosted + * environment (e.g. demo provisioning). + */ +export interface AssertSeedSafeOptions { + supabaseUrl: string + allowProd: boolean +} + +export function assertSeedSafe(options: AssertSeedSafeOptions): void { + const { supabaseUrl, allowProd } = options + + if (allowProd) return + + const lower = supabaseUrl.toLowerCase() + const isHosted = lower.includes("supabase.co") || lower.includes("supabase.com") + + if (isHosted) { + throw new Error( + `Refusing to seed against hosted Supabase (target: ${supabaseUrl}). ` + + `Set ALLOW_SEED_PROD=true to override (only for demo provisioning).` + ) + } +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 53b4183e..37c0788a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -13,6 +13,7 @@ import { createClient } from "@supabase/supabase-js" import { PrismaClient } from "@prisma/client" import { config } from "dotenv" +import { assertSeedSafe } from "./seed-guard" // Load env files (Next.js priority: .env.local > .env) config({ path: ".env.local" }) @@ -31,6 +32,11 @@ if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { process.exit(1) } +assertSeedSafe({ + supabaseUrl: SUPABASE_URL, + allowProd: process.env.ALLOW_SEED_PROD === "true", +}) + const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }) diff --git a/scripts/check-no-secrets.sh b/scripts/check-no-secrets.sh new file mode 100755 index 00000000..bc5bb4a2 --- /dev/null +++ b/scripts/check-no-secrets.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# check-no-secrets.sh +# +# Scans STAGED file content for known secret patterns before commit. +# Exits non-zero if a likely secret is found. +# +# Patterns chosen to be high-signal / low-noise: +# - Provider-specific prefixes (sk-ant-, sk-proj-, sk_live_, whsec_, AIza..., AKIA..., ghp_/gho_/ghs_/ghu_, xox[b-s]-) +# - Private keys (BEGIN ... PRIVATE KEY) +# - JWT tokens carrying role: service_role +# - DB connection strings with embedded credentials, except local dev +# +# Files known to be safe by design are skipped: +# - .env.example, *.example, *.template, *.sample +# - prisma/seed-guard.test.ts (contains pattern-shaped strings for tests) +# - this script itself + the pre-commit hook +# - docs and retro files reference patterns by name +# +# Override an unavoidable match by adding the literal string "secret-scan:allow" +# on the same line in the diff. Use sparingly. + +set -uo pipefail + +STAGED=$(git diff --cached --name-only --diff-filter=AM) +if [ -z "$STAGED" ]; then + exit 0 +fi + +# Skip files where pattern-shaped strings are expected. +SKIP_REGEX='(^|/)(\.env\.example|.+\.example|.+\.template|.+\.sample)$|(^|/)scripts/check-no-secrets\.(sh|test\.ts)$|(^|/)\.husky/pre-commit$|(^|/)prisma/seed-guard\.test\.ts$|(^|/)docs/.+\.md$|(^|/)\.claude/.+\.md$' + +FILES=() +while IFS= read -r f; do + [ -z "$f" ] && continue + if echo "$f" | grep -qE "$SKIP_REGEX"; then + continue + fi + FILES+=("$f") +done <<< "$STAGED" + +if [ ${#FILES[@]} -eq 0 ]; then + exit 0 +fi + +# Patterns. Each line: