diff --git a/package.json b/package.json index 28369d5..0a81a08 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "security:gates": "npx tsx check_signal_gate.ts", "security:gates:negative": "npx tsx test-sync-malicious.ts", "drift-check": "npx tsx scripts/sync-drift-keys.ts", - "reconcile:orphans": "npx tsx scripts/orphan-reconciler.ts" + "reconcile:orphans": "npx tsx scripts/orphan-reconciler.ts", + "test:drift-check": "npx tsx scripts/test-drift-check.ts", + "test:sync-drift-keys": "npx tsx scripts/test-sync-drift-keys.ts" }, "dependencies": { "@radix-ui/react-accordion": "^1.1.2", diff --git a/scripts/drift-check.ts b/scripts/drift-check.ts index a13900b..c9d240e 100644 --- a/scripts/drift-check.ts +++ b/scripts/drift-check.ts @@ -1,4 +1,5 @@ import { InfisicalSDK } from "@infisical/sdk"; +import { pathToFileURL } from "node:url"; import { ALIAS_GROUPS, P0_KEYS, P1_KEYS } from "./parity-manifest.js"; const INFISICAL_PROJECT_ID = "6c7646e9-04dd-484a-a5d1-612b9582da15"; @@ -88,24 +89,27 @@ function reportDrift( console.warn(`⚠️ WARNING: ${message}`); } +/** + * Reads a required environment variable and rejects values that are missing, + * empty, or whitespace-only, so a misconfigured (but present) secret can't + * silently sail through as a valid credential. + */ +export function requireEnv(name: string): string { + const raw = process.env[name]; + if (raw === undefined || raw.trim().length === 0) { + throw new Error(`${name} is not set (or is empty/whitespace-only)`); + } + return raw.trim(); +} + async function runDriftCheck(): Promise { const strictness = process.env.STRICTNESS || "warn"; - const infisicalToken = process.env.INFISICAL_TOKEN; - const netlifyAuthToken = process.env.NETLIFY_AUTH_TOKEN; - const netlifySiteId = process.env.NETLIFY_SITE_ID; + const infisicalToken = requireEnv("INFISICAL_TOKEN"); + const netlifyAuthToken = requireEnv("NETLIFY_AUTH_TOKEN"); + const netlifySiteId = requireEnv("NETLIFY_SITE_ID"); console.log(`[Guardrails] Starting drift check with strictness: ${strictness}`); - if (!infisicalToken) { - throw new Error("INFISICAL_TOKEN is not set"); - } - if (!netlifyAuthToken) { - throw new Error("NETLIFY_AUTH_TOKEN is not set"); - } - if (!netlifySiteId) { - throw new Error("NETLIFY_SITE_ID is not set"); - } - const [infisicalKeys, netlifyKeys] = await Promise.all([ fetchInfisicalKeys(infisicalToken), fetchNetlifyKeys(netlifyAuthToken, netlifySiteId), @@ -124,7 +128,13 @@ async function runDriftCheck(): Promise { reportDrift(strictness, missingOnNetlify, missingInInfisical); } -runDriftCheck().catch((error) => { - console.error(error); - process.exit(1); -}); +const isDirectExecution = + Boolean(process.argv[1]) && + import.meta.url === pathToFileURL(process.argv[1]!).href; + +if (isDirectExecution) { + runDriftCheck().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/parity-manifest.ts b/scripts/parity-manifest.ts index 8c51c36..3b0e83e 100644 --- a/scripts/parity-manifest.ts +++ b/scripts/parity-manifest.ts @@ -1,6 +1,8 @@ /** * Canonical parity manifest for Infisical ↔ Netlify drift checks. - * Keep aligned with career-navigator/scripts/sync-infisical-vault.ts PARITY_KEYS. + * Keep aligned with this repo's scripts/sync-infisical-vault.ts ALLOWLIST + * (there is no `career-navigator` copy of this list, and it never exported + * a `PARITY_KEYS` constant). */ /** P0 — hard fail when missing on Netlify (staging/prod). */ diff --git a/scripts/sync-drift-keys.ts b/scripts/sync-drift-keys.ts index 408a4f4..0310092 100644 --- a/scripts/sync-drift-keys.ts +++ b/scripts/sync-drift-keys.ts @@ -1,4 +1,5 @@ import { InfisicalSDK } from "@infisical/sdk"; +import { pathToFileURL } from "node:url"; import { ALIAS_GROUPS, P0_KEYS, P1_KEYS } from "./parity-manifest.js"; const INFISICAL_PROJECT_ID = "6c7646e9-04dd-484a-a5d1-612b9582da15"; @@ -15,11 +16,18 @@ const CANONICAL_VALUES: Record = { NOTION_DRIFT_REPORT_DB_ID: "398bc9d7494c819494cfdb5b41de8f6d", }; +interface NetlifyEnvVarValue { + context?: string; + /** Branch name when `context` is "branch"/"branch-deploy" overrides for a specific branch. */ + context_parameter?: string; + value?: string; +} + interface NetlifyEnvVar { id?: string; key?: string; scopes?: string[]; - values?: { context?: string; value?: string }[]; + values?: NetlifyEnvVarValue[]; } function resolveAlias(key: string, keys: Set): string | null { @@ -108,7 +116,34 @@ async function upsertInfisicalSecret( console.log(` + Infisical created ${key}`); } -async function upsertNetlifySecret( +/** + * Builds the full `values` array to send to Netlify for a secret update. + * + * Netlify's env-var PATCH/POST bodies replace the entire `values` array, so + * naively sending a single `{ context, value }` pair — as this used to do by + * reading only `existing.values[0]` — silently deletes any other deployment + * context overrides (Preview, Branch deploys) the variable already had. + * Instead, preserve every existing context (and its `context_parameter`, + * used for a specific branch override) and apply the new value to each one. + */ +export function buildNetlifyValuesPayload( + value: string, + existingValues: NetlifyEnvVarValue[] | undefined +): NetlifyEnvVarValue[] { + if (!existingValues || existingValues.length === 0) { + return [{ context: "all", value }]; + } + + return existingValues.map((entry) => ({ + context: entry.context ?? "all", + ...(entry.context_parameter !== undefined + ? { context_parameter: entry.context_parameter } + : {}), + value, + })); +} + +export async function upsertNetlifySecret( authToken: string, accountId: string, siteId: string, @@ -117,7 +152,7 @@ async function upsertNetlifySecret( existing: NetlifyEnvVar | undefined ): Promise { const scopes = existing?.scopes ?? ["builds", "functions", "runtime"]; - const context = existing?.values?.[0]?.context ?? "all"; + const values = buildNetlifyValuesPayload(value, existing?.values); const siteScopedEnvUrl = `https://api.netlify.com/api/v1/accounts/${accountId}/env/${encodeURIComponent(key)}?site_id=${siteId}`; const headers = { @@ -125,7 +160,7 @@ async function upsertNetlifySecret( Accept: "application/json", "Content-Type": "application/json", }; - const valuePayload = { values: [{ context, value }] }; + const valuePayload = { values }; const patchExisting = async (): Promise => fetch(siteScopedEnvUrl, { @@ -139,11 +174,12 @@ async function upsertNetlifySecret( if (!res.ok) { throw new Error(`Netlify update ${key} failed: ${res.status} ${await res.text()}`); } - console.log(` ↻ Netlify updated ${key}`); + const contexts = values.map((entry) => entry.context ?? "all").join(", "); + console.log(` ↻ Netlify updated ${key} (preserved contexts: ${contexts})`); return; } - const envVar = { key, scopes, values: [{ context, value }] }; + const envVar = { key, scopes, values }; const createRes = await fetch( `https://api.netlify.com/api/v1/accounts/${accountId}/env?site_id=${siteId}`, { @@ -253,7 +289,13 @@ async function main(): Promise { console.log("[sync] Done. Re-run npm run drift-check to verify."); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +const isDirectExecution = + Boolean(process.argv[1]) && + import.meta.url === pathToFileURL(process.argv[1]!).href; + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/test-drift-check.ts b/scripts/test-drift-check.ts new file mode 100644 index 0000000..eeb4404 --- /dev/null +++ b/scripts/test-drift-check.ts @@ -0,0 +1,60 @@ +/** Unit tests for drift-check helpers (no live Netlify/Infisical calls). */ +import assert from "node:assert/strict"; +import { requireEnv } from "./drift-check.js"; + +function test(name: string, fn: () => void): void { + try { + fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + throw error; + } +} + +function withEnv(name: string, value: string | undefined, fn: () => void): void { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +test("requireEnv returns the trimmed value when set", () => { + withEnv("TEST_DRIFT_CHECK_VAR", " a-real-token ", () => { + assert.equal(requireEnv("TEST_DRIFT_CHECK_VAR"), "a-real-token"); + }); +}); + +test("requireEnv rejects a missing variable", () => { + withEnv("TEST_DRIFT_CHECK_MISSING", undefined, () => { + assert.throws( + () => requireEnv("TEST_DRIFT_CHECK_MISSING"), + /TEST_DRIFT_CHECK_MISSING is not set/ + ); + }); +}); + +test("requireEnv rejects an empty string", () => { + withEnv("TEST_DRIFT_CHECK_EMPTY", "", () => { + assert.throws( + () => requireEnv("TEST_DRIFT_CHECK_EMPTY"), + /TEST_DRIFT_CHECK_EMPTY is not set/ + ); + }); +}); + +test("requireEnv rejects a whitespace-only string", () => { + withEnv("TEST_DRIFT_CHECK_WHITESPACE", " \t\n ", () => { + assert.throws( + () => requireEnv("TEST_DRIFT_CHECK_WHITESPACE"), + /TEST_DRIFT_CHECK_WHITESPACE is not set/ + ); + }); +}); + +console.log("All drift-check helper tests passed."); diff --git a/scripts/test-sync-drift-keys.ts b/scripts/test-sync-drift-keys.ts new file mode 100644 index 0000000..8a20120 --- /dev/null +++ b/scripts/test-sync-drift-keys.ts @@ -0,0 +1,122 @@ +/** Unit tests for sync-drift-keys helpers (no live Netlify/Infisical calls). */ +import assert from "node:assert/strict"; +import { buildNetlifyValuesPayload, upsertNetlifySecret } from "./sync-drift-keys.js"; + +async function test(name: string, fn: () => Promise | void): Promise { + try { + await fn(); + console.log(`✅ ${name}`); + } catch (error) { + console.error(`❌ ${name}`); + throw error; + } +} + +interface RecordedCall { + url: string; + method?: string; + body?: unknown; +} + +async function withMockFetch( + handler: (url: string, init?: RequestInit) => Response | Promise, + fn: () => Promise +): Promise { + const original = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => + handler(String(input), init)) as typeof fetch; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +async function main(): Promise { + await test("buildNetlifyValuesPayload defaults to a single 'all' context when none exists", () => { + assert.deepEqual(buildNetlifyValuesPayload("v1", undefined), [ + { context: "all", value: "v1" }, + ]); + assert.deepEqual(buildNetlifyValuesPayload("v1", []), [ + { context: "all", value: "v1" }, + ]); + }); + + await test("buildNetlifyValuesPayload preserves every existing context, including branch overrides", () => { + const result = buildNetlifyValuesPayload("new-value", [ + { context: "production", value: "old-prod" }, + { context: "deploy-preview", value: "old-preview" }, + { context: "branch-deploy", context_parameter: "staging", value: "old-branch" }, + ]); + + assert.deepEqual(result, [ + { context: "production", value: "new-value" }, + { context: "deploy-preview", value: "new-value" }, + { context: "branch-deploy", context_parameter: "staging", value: "new-value" }, + ]); + }); + + await test("upsertNetlifySecret PATCHes all existing contexts instead of clobbering them", async () => { + const calls: RecordedCall[] = []; + + await withMockFetch( + (url, init) => { + calls.push({ + url, + method: init?.method, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }); + return new Response(JSON.stringify({}), { status: 200 }); + }, + () => + upsertNetlifySecret("token", "account-1", "site-1", "SUPABASE_URL", "new-value", { + key: "SUPABASE_URL", + scopes: ["builds", "functions", "runtime"], + values: [ + { context: "production", value: "old-prod" }, + { context: "deploy-preview", value: "old-preview" }, + { context: "branch-deploy", context_parameter: "staging", value: "old-branch" }, + ], + }) + ); + + assert.equal(calls.length, 1, "expected exactly one PATCH request for an existing var"); + assert.equal(calls[0].method, "PATCH"); + const sentValues = (calls[0].body as { values: Array> }).values; + assert.equal(sentValues.length, 3, "all three contexts must be preserved, not just the first"); + + const byContext = new Map(sentValues.map((entry) => [entry.context, entry])); + assert.equal(byContext.get("production")?.value, "new-value"); + assert.equal(byContext.get("deploy-preview")?.value, "new-value"); + assert.equal(byContext.get("branch-deploy")?.value, "new-value"); + assert.equal(byContext.get("branch-deploy")?.context_parameter, "staging"); + }); + + await test("upsertNetlifySecret creates a single 'all' context value when the key is new", async () => { + const calls: RecordedCall[] = []; + + await withMockFetch( + (url, init) => { + calls.push({ + url, + method: init?.method, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }); + return new Response(JSON.stringify({}), { status: 200 }); + }, + () => upsertNetlifySecret("token", "account-1", "site-1", "NEW_KEY", "v1", undefined) + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].method, "POST"); + const body = calls[0].body as Array<{ values: Array> }>; + assert.deepEqual(body[0].values, [{ context: "all", value: "v1" }]); + }); + + console.log("All sync-drift-keys helper tests passed."); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});