feat: add env var drift detection endpoints for Vercel deployments#756
feat: add env var drift detection endpoints for Vercel deployments#756kilo-code-bot[bot] wants to merge 2 commits intomainfrom
Conversation
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
Outdated
| }; | ||
| } | ||
|
|
||
| const keys: string[] = await response.json(); |
There was a problem hiding this comment.
[WARNING]: No validation of the response body
The response from /api/env-keys is cast to string[] without any runtime validation. If a remote deployment returns an error object (e.g., {"error": "Unauthorized"}) that still has a 200 status, or if the response shape changes, this will silently produce incorrect results — new Set(...) on a non-array could behave unexpectedly.
Consider adding a runtime check, e.g.:
const body = await response.json();
if (!Array.isArray(body)) {
return { name: deployment.name, keys: null, error: `Unexpected response format` };
}| secret: string | ||
| ): Promise<{ name: string; keys: Set<string> | null; error: string | null }> { | ||
| try { | ||
| const response = await fetch(`${deployment.url}/api/env-keys`, { |
There was a problem hiding this comment.
[WARNING]: No fetch timeout
The fetch call has no timeout configured. If a deployment is unresponsive, this request will hang indefinitely since Promise.all waits for all promises. Consider adding an AbortSignal.timeout() to prevent long hangs:
const response = await fetch(`${deployment.url}/api/env-keys`, {
headers: { Authorization: `Bearer ${secret}` },
signal: AbortSignal.timeout(10_000),
});| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||
| } | ||
|
|
||
| const configuredDeployments = DEPLOYMENTS.filter(d => d.url); |
There was a problem hiding this comment.
[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 }
);
}| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||
| } | ||
|
|
||
| const keys = Object.keys(process.env) |
There was a problem hiding this comment.
[WARNING]: Exposes system-level environment variable names
Object.keys(process.env) includes not just application env vars but also OS/system-level variables like PATH, HOME, USER, HOSTNAME, LANG, NODE_VERSION, etc. These can leak infrastructure details (OS type, Node version, container info). The IGNORED_KEYS set only filters Vercel-managed vars.
Consider either:
- Using an allowlist approach (only return keys matching a known prefix like
NEXT_PUBLIC_, or keys from your.envschema) - Adding a broader denylist that also excludes common system vars
- Filtering to only keys that start with known application prefixes
Code Review SummaryStatus: 7 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Other Observations (not in diff)Issues found that cannot receive inline comments:
Files Reviewed (2 files)
|
/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.
| request: NextRequest | ||
| ): Promise<NextResponse<EnvCheckSuccess | EnvCheckDrift | EnvCheckError | { error: string }>> { | ||
| const authHeader = request.headers.get('authorization'); | ||
| if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { |
There was a problem hiding this comment.
[WARNING]: Timing-unsafe secret comparison
The !== operator is vulnerable to timing attacks. The rest of the codebase consistently uses crypto.timingSafeEqual for secret comparisons (see src/lib/slack/verify-request.ts, src/lib/integrations/platforms/gitlab/adapter.ts, src/app/discord/webhook/route.ts, etc.).
Use crypto.timingSafeEqual(Buffer.from(authHeader ?? ''), Buffer.from(Bearer ${ENV_CHECK_SECRET})) instead, with a length check guard to prevent the timingSafeEqual throw on mismatched lengths.
| request: NextRequest | ||
| ): Promise<NextResponse<{ error: string } | Record<string, string>>> { | ||
| const authHeader = request.headers.get('authorization'); | ||
| if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { |
There was a problem hiding this comment.
[WARNING]: Timing-unsafe secret comparison
Same issue as in env-check/route.ts — the !== operator leaks timing information about the secret. Use crypto.timingSafeEqual with a length guard, consistent with the rest of the codebase.
|
|
||
| const hashedEntries: Record<string, string> = {}; | ||
| for (const key of keys) { | ||
| hashedEntries[key] = sha256(process.env[key] ?? '__undefined__'); |
There was a problem hiding this comment.
[SUGGESTION]: Dead fallback — ?? '__undefined__' can never trigger
Since keys comes from Object.keys(process.env), every key is guaranteed to exist in process.env, so process.env[key] will always be a string (never undefined). The ?? '__undefined__' fallback is dead code and could mislead future readers into thinking undefined values are a real possibility here.
| hashedEntries[key] = sha256(process.env[key] ?? '__undefined__'); | |
| hashedEntries[key] = sha256(process.env[key] ?? ''); |
Summary
GET /api/env-keys— returns a map of runtimeprocess.envkeys to SHA-256 hashed values (excluding Vercel-managed vars likeVERCEL_URL,VERCEL_GIT_COMMIT_SHA, etc.), protected byENV_CHECK_SECRETbearer token. Undefined values are hashed as"__undefined__".GET /api/env-check— fetches/api/env-keysfrom all three Vercel deployments (kilocode-app,kilocode-global-app,kilocode-app-staging), compares both key presence and hashed values, and reports which keys are missing or have mismatched values across deployments./api/env-checkresponse — only key names and drift type (missing or mismatched).Required env vars
ENV_CHECK_SECRETKILOCODE_APP_URLkilocode-appdeploymentKILOCODE_GLOBAL_APP_URLkilocode-global-appdeploymentKILOCODE_STAGING_APP_URLkilocode-app-stagingdeploymentResponse examples
/api/env-keys— returns object mapping env key names to SHA-256 hashed values:{"APP_BUILDER_AUTH_TOKEN": "a1b2c3...", "APP_BUILDER_URL": "d4e5f6...", ...}/api/env-check— all match:{ "status": "ok", "message": "All deployments have matching env vars", "deployments": ["kilocode-app", "kilocode-global-app", "kilocode-app-staging"] }/api/env-check— drift detected (missing key):{ "status": "drift", "message": "Found 2 env var(s) with drift across deployments", "differences": [{ "key": "SOME_NEW_VAR", "missingFrom": ["kilocode-app-staging"] }] }/api/env-check— drift detected (value mismatch):{ "status": "drift", "message": "Found 1 env var(s) with drift across deployments", "differences": [{ "key": "DATABASE_URL", "mismatchBetween": ["kilocode-app", "kilocode-global-app", "kilocode-app-staging"] }] }Built for Mark by Kilo for Slack