Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 27 additions & 17 deletions scripts/drift-check.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void> {
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");
Comment on lines +107 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Automation bypasses credential validation

The requireEnv checks guard an unused entry point; the configured drift command executes another script. Automation still accepts whitespace credentials and fails later.

Prompt for agents
The GitHub drift-check workflow runs npm run drift-check, and package.json maps that command to scripts/sync-drift-keys.ts rather than scripts/drift-check.ts. Consequently, requireEnv does not validate the automated path. Decide which script is the intended drift-check implementation, wire the npm script and workflow to it, or share the validation with sync-drift-keys.ts. Ensure every credential actually consumed by the selected script is trimmed and rejected when empty or whitespace-only, including supported fallback variable names.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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),
Expand All @@ -124,7 +128,13 @@ async function runDriftCheck(): Promise<void> {
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);
});
}
4 changes: 3 additions & 1 deletion scripts/parity-manifest.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down
62 changes: 52 additions & 10 deletions scripts/sync-drift-keys.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,11 +16,18 @@ const CANONICAL_VALUES: Record<string, string> = {
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>): string | null {
Expand Down Expand Up @@ -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,
Expand All @@ -117,15 +152,15 @@ async function upsertNetlifySecret(
existing: NetlifyEnvVar | undefined
): Promise<void> {
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 = {
Authorization: `Bearer ${authToken}`,
Accept: "application/json",
"Content-Type": "application/json",
};
const valuePayload = { values: [{ context, value }] };
const valuePayload = { values };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Existing Netlify variables cannot update

When a key exists, valuePayload uses PUT's array schema for a PATCH endpoint that accepts one context value. Netlify rejects the update, stopping synchronization.

Prompt for agents
The Netlify endpoint at /api/v1/accounts/{accountId}/env/{key} uses PATCH to set one contextual value with a body shaped as { context, context_parameter?, value }. Replacing all values uses PUT with the full environment-variable body. upsertNetlifySecret currently sends { values: [...] } through PATCH for both existing keys and create-conflict fallback, so real updates fail. Choose the appropriate API operation, preserve all required variable metadata if using PUT, and add a mock that validates the request method and exact body against Netlify's API contract. Also account for the create-conflict path, where the current values were not loaded.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const patchExisting = async (): Promise<Response> =>
fetch(siteScopedEnvUrl, {
Expand All @@ -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}`,
{
Expand Down Expand Up @@ -253,7 +289,13 @@ async function main(): Promise<void> {
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);
});
}
60 changes: 60 additions & 0 deletions scripts/test-drift-check.ts
Original file line number Diff line number Diff line change
@@ -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.");
122 changes: 122 additions & 0 deletions scripts/test-sync-drift-keys.ts
Original file line number Diff line number Diff line change
@@ -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> | void): Promise<void> {
try {
await fn();
console.log(`✅ ${name}`);
} catch (error) {
console.error(`❌ ${name}`);
throw error;
}
}

interface RecordedCall {
url: string;
method?: string;
body?: unknown;
}

async function withMockFetch<T>(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>,
fn: () => Promise<T>
): Promise<T> {
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<void> {
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" },
],
})
);
Comment on lines +76 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test Bypasses Context Lookup

The regression test injects production, preview, and branch values directly into upsertNetlifySecret, while the real repair path first fetches variables with context_name=production. It therefore remains green without validating that the operational lookup supplies every context the update is intended to preserve.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/test-sync-drift-keys.ts
Line: 76-81

Comment:
**Test Bypasses Context Lookup**

The regression test injects production, preview, and branch values directly into `upsertNetlifySecret`, while the real repair path first fetches variables with `context_name=production`. It therefore remains green without validating that the operational lookup supplies every context the update is intended to preserve.

**Knowledge Base Used:**
- [Data, security, and platform operations](https://app.greptile.com/palmtree-studios/-/custom-context/knowledge-base/palmtr3man/thispagedoesnotexist12345/-/docs/data-security-platform.md)
- [Deployment security operations](https://app.greptile.com/palmtree-studios/-/custom-context/knowledge-base/palmtr3man/thispagedoesnotexist12345/-/docs/deployment-security-operations.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex Fix in Conductor


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<Record<string, string>> }).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<Record<string, string>> }>;
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;
});
Loading