From 9184d811dcd0e352586340808e1377d0f5474316 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:03:49 -0500 Subject: [PATCH] Expand Infisical secret references when reading Fleet-OPS root (OPS-818) The Fleet-OPS root secrets are now bare references such as ${prod..}. The reader asked Infisical not to expand them, so it would have exported the literal reference as the credential. - `scripts/infisical-fetch.mjs`: `expandSecretReferences` is now `true` on the secrets list call, and a value that is still a bare reference makes the step fail naming the key only. This copy has no Fleet-OPS root caller today; it is fixed so it cannot hand out a reference later. - `scripts/infisical-fetch-expand.node-test.mjs`: new test for the expansion parameter and the guard. Refs OPS-818 Co-Authored-By: Claude Opus 5.5 --- scripts/infisical-fetch-expand.node-test.mjs | 103 +++++++++++++++++++ scripts/infisical-fetch.mjs | 31 +++++- 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 scripts/infisical-fetch-expand.node-test.mjs diff --git a/scripts/infisical-fetch-expand.node-test.mjs b/scripts/infisical-fetch-expand.node-test.mjs new file mode 100644 index 00000000..8d6aa5a1 --- /dev/null +++ b/scripts/infisical-fetch-expand.node-test.mjs @@ -0,0 +1,103 @@ +// OPS-818: infisical-fetch.mjs must ask Infisical to expand secret references +// and must fail closed (naming the KEY only) when a value is still a bare +// reference. Run: node --test scripts/infisical-fetch-expand.node-test.mjs +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { isUnresolvedReference, run } from "./infisical-fetch.mjs"; + +const REFERENCE = "${prod.global-api-keys.SENTINEL_KEY_ref_3c9e}"; +const RESOLVED = "sentinel-resolved-value-5d1a"; + +/** Stub Infisical: login succeeds, the list call returns the fixture secrets + * and records every list URL so a test can assert on the query string. */ +function stubFetch(vaultSecrets, listUrls = []) { + return async (url) => { + const u = String(url); + if (u.includes("/api/v1/auth/universal-auth/login")) { + return { ok: true, json: async () => ({ accessToken: "test-token", expiresIn: 3600 }) }; + } + if (u.includes("/api/v3/secrets/raw")) { + listUrls.push(u); + return { + ok: true, + json: async () => ({ + secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ secretKey, secretValue })), + }), + }; + } + throw new Error(`stubFetch: unexpected URL ${u}`); + }; +} + +function baseEnv(githubEnv, names) { + return { + INFISICAL_NAMES: names, + INFISICAL_PROJECT_ID: "proj", + INFISICAL_CLIENT_ID: "id", + INFISICAL_CLIENT_SECRET: "secret", + GITHUB_ENV: githubEnv, + }; +} + +test("the secrets list asks Infisical to expand secret references", async () => { + const dir = mkdtempSync(join(tmpdir(), "ops818-fetch-")); + try { + const listUrls = []; + await run({ env: baseEnv(join(dir, "env"), "FOO"), fetchImpl: stubFetch({ FOO: RESOLVED }, listUrls), log: () => {} }); + assert.equal(listUrls.length, 1); + assert.equal(new URL(listUrls[0]).searchParams.get("expandSecretReferences"), "true"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a value that is still a bare reference fails closed and names the key only", async () => { + const dir = mkdtempSync(join(tmpdir(), "ops818-fetch-")); + try { + const envFile = join(dir, "env"); + const lines = []; + await assert.rejects( + run({ + env: baseEnv(envFile, "GOOD BAD"), + fetchImpl: stubFetch({ GOOD: RESOLVED, BAD: REFERENCE }), + log: (l) => lines.push(l), + }), + (err) => { + assert.match(err.message, /BAD/); + assert.ok(!err.message.includes("SENTINEL_KEY_ref_3c9e"), "error must not echo the reference"); + return true; + }, + ); + const written = existsSync(envFile) ? readFileSync(envFile, "utf8") : ""; + assert.ok(!written.includes("SENTINEL_KEY_ref_3c9e"), "reference must never reach GITHUB_ENV"); + assert.ok(lines.every((l) => !l.includes("SENTINEL_KEY_ref_3c9e")), "reference must never reach the log"); + assert.ok(lines.some((l) => l.startsWith("::error::BAD ")), "the key is named in an ::error:: line"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a resolved value is written normally", async () => { + const dir = mkdtempSync(join(tmpdir(), "ops818-fetch-")); + try { + const envFile = join(dir, "env"); + await run({ env: baseEnv(envFile, "FOO"), fetchImpl: stubFetch({ FOO: RESOLVED }), log: () => {} }); + assert.ok(readFileSync(envFile, "utf8").includes(RESOLVED)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("isUnresolvedReference matches only a whole-value bare reference", () => { + assert.equal(isUnresolvedReference("${prod.global-api-keys.NAME}"), true); + assert.equal(isUnresolvedReference(" ${prod.a.B}\n"), true); + assert.equal(isUnresolvedReference("prefix-${prod.a.B}"), false); + assert.equal(isUnresolvedReference("${prod.a.B}-suffix"), false); + assert.equal(isUnresolvedReference("plain"), false); + assert.equal(isUnresolvedReference(""), false); + assert.equal(isUnresolvedReference(undefined), false); +}); diff --git a/scripts/infisical-fetch.mjs b/scripts/infisical-fetch.mjs index b1be6fb1..95937de2 100644 --- a/scripts/infisical-fetch.mjs +++ b/scripts/infisical-fetch.mjs @@ -60,14 +60,20 @@ export async function loginUniversalAuth({ siteUrl, clientId, clientSecret, fetc /** GET .../api/v3/secrets/raw for the whole path in one call -- returns a * Map. viewSecretValue is always "true" here; this * script only ever runs where values are needed (it exists to populate - * GITHUB_ENV), unlike the probe path in server/infisical.ts. */ + * GITHUB_ENV), unlike the probe path in server/infisical.ts. + * + * expandSecretReferences is "true": a secret whose stored value is a bare + * reference such as ${prod.some-folder.NAME} (the Fleet-OPS root, OPS-818) comes + * back as the referenced value. With "false" the literal reference string + * would land in GITHUB_ENV as if it were the credential. Expanding needs the + * reading identity to be able to read the referenced folder too. */ export async function listSecretsRaw({ siteUrl, token, projectId, environment, secretPath, fetchImpl = fetch, timeoutMs = 30_000 }) { const qs = new URLSearchParams({ workspaceId: projectId, environment, secretPath, viewSecretValue: "true", - expandSecretReferences: "false", + expandSecretReferences: "true", include_imports: "false", }); const res = await fetchImpl(`${siteUrl}/api/v3/secrets/raw?${qs.toString()}`, { @@ -128,6 +134,16 @@ export function writeGithubEnvValue(path, name, value, appendFileImpl = appendFi appendFileImpl(path, `${name}<<${delimiter}\n${value}\n${delimiter}\n`); } +/** A value that is still a bare Infisical reference such as + * ${prod.some-folder.NAME}: the server did not expand it (target missing, or + * the reading identity cannot read the referenced folder). Exporting it would + * hand a workflow the literal reference string as if it were the credential. */ +export const UNRESOLVED_REFERENCE = /^\$\{[^}]+\}$/; + +export function isUnresolvedReference(value) { + return UNRESOLVED_REFERENCE.test(String(value ?? "").trim()); +} + /** Resolves the vault snapshot (empty map when unconfigured or the fetch * failed) -- never throws; a failure is reported through `log` and the * caller proceeds on GH_FALLBACK_ alone. */ @@ -162,6 +178,7 @@ export async function run({ env = process.env, fetchImpl = fetch, log = (line) = const vaultValues = await resolveVaultValues({ siteUrl, projectId, clientId, clientSecret, environment, secretPath, fetchImpl, log }); const missingRequired = []; + const unresolved = []; for (const name of names) { let value = vaultValues.get(name) || ""; if (!value) { @@ -177,10 +194,20 @@ export async function run({ env = process.env, fetchImpl = fetch, log = (line) = } continue; } + if (isUnresolvedReference(value)) { + // Name the KEY only -- never the value, even though it is a reference. + unresolved.push(name); + log(`::error::${name} is still an unresolved Infisical secret reference after the ${environment} export; check the target exists and the CI identity can read the referenced folder.`); + continue; + } maskValue(value, log); writeGithubEnvValue(githubEnvPath, name, value); } + if (unresolved.length > 0) { + throw new Error(`Unresolved Infisical secret reference(s): ${unresolved.join(", ")}`); + } + if (missingRequired.length > 0) { throw new Error(`Missing required Infisical name(s): ${missingRequired.join(", ")}`); }