-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add env var drift detection endpoints for Vercel deployments #756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| 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 KeyDrift = { | ||
| key: string; | ||
| missingFrom?: string[]; | ||
| mismatchBetween?: string[]; | ||
| }; | ||
|
|
||
| type EnvCheckDrift = { | ||
| status: 'drift'; | ||
| message: string; | ||
| differences: KeyDrift[]; | ||
| }; | ||
|
|
||
| type EnvCheckError = { | ||
| status: 'error'; | ||
| message: string; | ||
| failures: { name: string; error: string }[]; | ||
| }; | ||
|
|
||
| type FetchResult = { | ||
| name: string; | ||
| entries: Record<string, string> | null; | ||
| error: string | null; | ||
| }; | ||
|
|
||
| async function fetchEnvKeys(deployment: Deployment, secret: string): Promise<FetchResult> { | ||
| try { | ||
| const response = await fetch(`${deployment.url}/api/env-keys`, { | ||
| headers: { Authorization: `Bearer ${secret}` }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| return { | ||
| name: deployment.name, | ||
| entries: null, | ||
| error: `HTTP ${response.status}: ${response.statusText}`, | ||
| }; | ||
| } | ||
|
|
||
| const entries: Record<string, string> = await response.json(); | ||
| return { name: deployment.name, entries, error: null }; | ||
| } catch (err) { | ||
| return { | ||
| name: deployment.name, | ||
| entries: null, | ||
| error: err instanceof Error ? err.message : 'Unknown error', | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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 | ||
| ): Promise<NextResponse<EnvCheckSuccess | EnvCheckDrift | EnvCheckError | { error: string }>> { | ||
| const authHeader = request.headers.get('authorization'); | ||
| if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: Timing-unsafe secret comparison The Use |
||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||
| } | ||
|
|
||
| const configuredDeployments = DEPLOYMENTS.filter(d => d.url); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: No minimum deployment count check If only one deployment URL is configured, the drift check runs but has nothing meaningful to compare against — it will always report "ok" since there's no second deployment to differ from. Consider requiring at least 2 configured deployments for a meaningful comparison: if (configuredDeployments.length < 2) {
return NextResponse.json(
{ error: 'At least 2 deployment URLs must be configured for drift detection' },
{ status: 500 }
);
} |
||
| 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<string>(); | ||
| for (const result of results) { | ||
| if (result.entries) { | ||
| for (const key of Object.keys(result.entries)) { | ||
| allKeys.add(key); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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.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) }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const deploymentNames = configuredDeployments.map(d => d.name); | ||
|
|
||
| if (differences.length === 0) { | ||
| return NextResponse.json({ | ||
| status: 'ok' as const, | ||
| message: 'All deployments have matching env vars', | ||
| deployments: deploymentNames, | ||
| }); | ||
| } | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
| status: 'drift' as const, | ||
| message: `Found ${differences.length} env var(s) with drift across deployments`, | ||
| differences, | ||
| }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,59 @@ | ||||||
| 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', | ||||||
| '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 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<NextResponse<{ error: string } | Record<string, string>>> { | ||||||
| const authHeader = request.headers.get('authorization'); | ||||||
| if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: Timing-unsafe secret comparison Same issue as in |
||||||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||||||
| } | ||||||
|
|
||||||
| const keys = Object.keys(process.env) | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: Exposes system-level environment variable names
Consider either:
|
||||||
| .filter(key => !IGNORED_KEYS.has(key)) | ||||||
| .sort(); | ||||||
|
|
||||||
| const hashedEntries: Record<string, string> = {}; | ||||||
| for (const key of keys) { | ||||||
| hashedEntries[key] = sha256(process.env[key] ?? '__undefined__'); | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION]: Dead fallback — Since
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| return NextResponse.json(hashedEntries); | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[WARNING]: No fetch timeout
The
fetchcall has no timeout configured. If a deployment is unresponsive, this request will hang indefinitely sincePromise.allwaits for all promises. Consider adding anAbortSignal.timeout()to prevent long hangs: