From 457b91ce3180adaa31589852e03c39dbfee76b1b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:06:27 +0000 Subject: [PATCH 1/2] feat: add env var drift detection endpoints for Vercel deployments Add two endpoints to detect environment variable drift across kilocode-app, kilocode-global-app, and kilocode-app-staging deployments: - GET /api/env-keys: returns runtime process.env keys (filtered to exclude Vercel-managed vars), protected by ENV_CHECK_SECRET bearer token - GET /api/env-check: fetches /api/env-keys from all three deployments, compares key sets, and reports missing keys per deployment --- src/app/api/env-check/route.ts | 146 +++++++++++++++++++++++++++++++++ src/app/api/env-keys/route.ts | 47 +++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/app/api/env-check/route.ts create mode 100644 src/app/api/env-keys/route.ts diff --git a/src/app/api/env-check/route.ts b/src/app/api/env-check/route.ts new file mode 100644 index 000000000..4192c5428 --- /dev/null +++ b/src/app/api/env-check/route.ts @@ -0,0 +1,146 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getEnvVariable } from '@/lib/dotenvx'; + +const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET'); + +type Deployment = { + name: string; + url: string; +}; + +const DEPLOYMENTS: Deployment[] = [ + { name: 'kilocode-app', url: getEnvVariable('KILOCODE_APP_URL') }, + { name: 'kilocode-global-app', url: getEnvVariable('KILOCODE_GLOBAL_APP_URL') }, + { name: 'kilocode-app-staging', url: getEnvVariable('KILOCODE_STAGING_APP_URL') }, +]; + +type EnvCheckSuccess = { + status: 'ok'; + message: string; + deployments: string[]; +}; + +type MissingKey = { + key: string; + missingFrom: string[]; +}; + +type EnvCheckDrift = { + status: 'drift'; + message: string; + differences: MissingKey[]; +}; + +type EnvCheckError = { + status: 'error'; + message: string; + failures: { name: string; error: string }[]; +}; + +async function fetchEnvKeys( + deployment: Deployment, + secret: string +): Promise<{ name: string; keys: Set | null; error: string | null }> { + try { + const response = await fetch(`${deployment.url}/api/env-keys`, { + headers: { Authorization: `Bearer ${secret}` }, + }); + + if (!response.ok) { + return { + name: deployment.name, + keys: null, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + + const keys: string[] = await response.json(); + return { name: deployment.name, keys: new Set(keys), error: null }; + } catch (err) { + return { + name: deployment.name, + keys: null, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } +} + +/** + * Fetches /api/env-keys from all three deployments, compares the key sets, + * and reports any drift. Protected by ENV_CHECK_SECRET bearer token. + * Never exposes actual env values — only key names and presence/absence. + */ +export async function GET( + request: NextRequest +): Promise> { + const authHeader = request.headers.get('authorization'); + if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const configuredDeployments = DEPLOYMENTS.filter(d => d.url); + if (configuredDeployments.length === 0) { + return NextResponse.json( + { error: 'No deployment URLs configured' }, + { status: 500 } + ); + } + + const results = await Promise.all( + configuredDeployments.map(d => fetchEnvKeys(d, ENV_CHECK_SECRET)) + ); + + const failures = results.filter(r => r.error !== null); + if (failures.length > 0) { + return NextResponse.json( + { + status: 'error' as const, + message: `Failed to fetch env keys from ${failures.length} deployment(s)`, + failures: failures.map(f => ({ name: f.name, error: f.error ?? 'Unknown error' })), + }, + { status: 502 } + ); + } + + // Collect the union of all keys across all deployments + const allKeys = new Set(); + for (const result of results) { + if (result.keys) { + for (const key of result.keys) { + allKeys.add(key); + } + } + } + + // Find keys missing from any deployment + const differences: MissingKey[] = []; + for (const key of [...allKeys].sort()) { + const missingFrom = results + .filter(r => r.keys && !r.keys.has(key)) + .map(r => r.name); + + if (missingFrom.length > 0) { + differences.push({ key, missingFrom }); + } + } + + const deploymentNames = configuredDeployments.map(d => d.name); + + if (differences.length === 0) { + return NextResponse.json({ + status: 'ok' as const, + message: 'All deployments have matching env var keys', + deployments: deploymentNames, + }); + } + + return NextResponse.json( + { + status: 'drift' as const, + message: `Found ${differences.length} env var key(s) with drift across deployments`, + differences, + }, + { status: 200 } + ); +} diff --git a/src/app/api/env-keys/route.ts b/src/app/api/env-keys/route.ts new file mode 100644 index 000000000..4c47b4a1b --- /dev/null +++ b/src/app/api/env-keys/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getEnvVariable } from '@/lib/dotenvx'; + +const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET'); + +// Vercel-managed env vars that legitimately differ between deployments +const IGNORED_KEYS = new Set([ + 'VERCEL', + 'VERCEL_ENV', + 'VERCEL_URL', + 'VERCEL_BRANCH_URL', + 'VERCEL_PROJECT_PRODUCTION_URL', + 'VERCEL_GIT_COMMIT_SHA', + 'VERCEL_GIT_COMMIT_MESSAGE', + 'VERCEL_GIT_COMMIT_AUTHOR_LOGIN', + 'VERCEL_GIT_COMMIT_AUTHOR_NAME', + 'VERCEL_GIT_COMMIT_REF', + 'VERCEL_GIT_PREVIOUS_SHA', + 'VERCEL_GIT_PROVIDER', + 'VERCEL_GIT_PULL_REQUEST_ID', + 'VERCEL_GIT_REPO_ID', + 'VERCEL_GIT_REPO_OWNER', + 'VERCEL_GIT_REPO_SLUG', + 'VERCEL_REGION', + 'VERCEL_DEPLOYMENT_ID', + 'VERCEL_SKEW_PROTECTION_ENABLED', + 'VERCEL_AUTOMATION_BYPASS_SECRET', +]); + +/** + * Returns the set of process.env keys available at runtime, + * filtered to exclude Vercel-managed vars that differ between deployments. + * Protected by ENV_CHECK_SECRET bearer token. + */ +export async function GET(request: NextRequest): Promise> { + const authHeader = request.headers.get('authorization'); + if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const keys = Object.keys(process.env) + .filter(key => !IGNORED_KEYS.has(key)) + .sort(); + + return NextResponse.json(keys); +} From a5711bfb9cc4b958ca3896149c4ac4d98854bca3 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:17:05 +0000 Subject: [PATCH 2/2] feat: hash env var values and detect value drift across deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/env-keys now returns key→SHA-256(value) pairs instead of just keys. /api/env-check compares both key presence and hashed values, reporting missing keys and value mismatches without exposing hashes in the response. --- src/app/api/env-check/route.ts | 54 +++++++++++++++++++++------------- src/app/api/env-keys/route.ts | 22 ++++++++++---- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/app/api/env-check/route.ts b/src/app/api/env-check/route.ts index 4192c5428..a2b090958 100644 --- a/src/app/api/env-check/route.ts +++ b/src/app/api/env-check/route.ts @@ -21,15 +21,16 @@ type EnvCheckSuccess = { deployments: string[]; }; -type MissingKey = { +type KeyDrift = { key: string; - missingFrom: string[]; + missingFrom?: string[]; + mismatchBetween?: string[]; }; type EnvCheckDrift = { status: 'drift'; message: string; - differences: MissingKey[]; + differences: KeyDrift[]; }; type EnvCheckError = { @@ -38,10 +39,13 @@ type EnvCheckError = { failures: { name: string; error: string }[]; }; -async function fetchEnvKeys( - deployment: Deployment, - secret: string -): Promise<{ name: string; keys: Set | null; error: string | null }> { +type FetchResult = { + name: string; + entries: Record | null; + error: string | null; +}; + +async function fetchEnvKeys(deployment: Deployment, secret: string): Promise { try { const response = await fetch(`${deployment.url}/api/env-keys`, { headers: { Authorization: `Bearer ${secret}` }, @@ -50,26 +54,26 @@ async function fetchEnvKeys( if (!response.ok) { return { name: deployment.name, - keys: null, + entries: null, error: `HTTP ${response.status}: ${response.statusText}`, }; } - const keys: string[] = await response.json(); - return { name: deployment.name, keys: new Set(keys), error: null }; + const entries: Record = await response.json(); + return { name: deployment.name, entries, error: null }; } catch (err) { return { name: deployment.name, - keys: null, + entries: null, error: err instanceof Error ? err.message : 'Unknown error', }; } } /** - * Fetches /api/env-keys from all three deployments, compares the key sets, - * and reports any drift. Protected by ENV_CHECK_SECRET bearer token. - * Never exposes actual env values — only key names and presence/absence. + * Fetches /api/env-keys from all three deployments, compares keys and hashed + * values, and reports any drift. Protected by ENV_CHECK_SECRET bearer token. + * Never exposes actual env values or hashes — only key names and drift type. */ export async function GET( request: NextRequest @@ -106,22 +110,30 @@ export async function GET( // Collect the union of all keys across all deployments const allKeys = new Set(); for (const result of results) { - if (result.keys) { - for (const key of result.keys) { + if (result.entries) { + for (const key of Object.keys(result.entries)) { allKeys.add(key); } } } - // Find keys missing from any deployment - const differences: MissingKey[] = []; + // Find keys missing from any deployment, or with mismatched hashed values + const differences: KeyDrift[] = []; for (const key of [...allKeys].sort()) { + const presentIn = results.filter(r => r.entries && key in r.entries); const missingFrom = results - .filter(r => r.keys && !r.keys.has(key)) + .filter(r => r.entries && !(key in r.entries)) .map(r => r.name); if (missingFrom.length > 0) { differences.push({ key, missingFrom }); + } else if (presentIn.length > 1) { + // All deployments have this key — check whether hashed values match + const hashes = presentIn.map(r => r.entries?.[key]); + const unique = new Set(hashes); + if (unique.size > 1) { + differences.push({ key, mismatchBetween: presentIn.map(r => r.name) }); + } } } @@ -130,7 +142,7 @@ export async function GET( if (differences.length === 0) { return NextResponse.json({ status: 'ok' as const, - message: 'All deployments have matching env var keys', + message: 'All deployments have matching env vars', deployments: deploymentNames, }); } @@ -138,7 +150,7 @@ export async function GET( return NextResponse.json( { status: 'drift' as const, - message: `Found ${differences.length} env var key(s) with drift across deployments`, + message: `Found ${differences.length} env var(s) with drift across deployments`, differences, }, { status: 200 } diff --git a/src/app/api/env-keys/route.ts b/src/app/api/env-keys/route.ts index 4c47b4a1b..c73a9d40b 100644 --- a/src/app/api/env-keys/route.ts +++ b/src/app/api/env-keys/route.ts @@ -1,9 +1,14 @@ +import { createHash } from 'crypto'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { getEnvVariable } from '@/lib/dotenvx'; const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET'); +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + // Vercel-managed env vars that legitimately differ between deployments const IGNORED_KEYS = new Set([ 'VERCEL', @@ -29,11 +34,13 @@ const IGNORED_KEYS = new Set([ ]); /** - * Returns the set of process.env keys available at runtime, - * filtered to exclude Vercel-managed vars that differ between deployments. - * Protected by ENV_CHECK_SECRET bearer token. + * Returns the set of process.env keys available at runtime with SHA-256 + * hashed values, filtered to exclude Vercel-managed vars that differ + * between deployments. Protected by ENV_CHECK_SECRET bearer token. */ -export async function GET(request: NextRequest): Promise> { +export async function GET( + request: NextRequest +): Promise>> { const authHeader = request.headers.get('authorization'); if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -43,5 +50,10 @@ export async function GET(request: NextRequest): Promise !IGNORED_KEYS.has(key)) .sort(); - return NextResponse.json(keys); + const hashedEntries: Record = {}; + for (const key of keys) { + hashedEntries[key] = sha256(process.env[key] ?? '__undefined__'); + } + + return NextResponse.json(hashedEntries); }