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
103 changes: 103 additions & 0 deletions scripts/infisical-fetch-expand.node-test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
31 changes: 29 additions & 2 deletions scripts/infisical-fetch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<secretKey, secretValue>. 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()}`, {
Expand Down Expand Up @@ -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_<NAME> alone. */
Expand Down Expand Up @@ -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) {
Expand All @@ -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(", ")}`);
}
Expand Down
Loading