Recipe test observes the secrets loadConfig reads

Soul pass 4, F3: the recipe test found Heimdall's secrets by parsing
readSecretInput calls out of the source, and an alias, a parenthesised
first argument or a colliding local name hid a new secret from it (S1-S3).

The test now runs the real loadConfig against an environment that records
every name looked up, standing in for process.env as well, and takes each
`_FILE` lookup as a secret: readSecretInput makes one per secret, however
the call is spelled and whatever list computes the name. Each must be
declared as `_FILE` in the recipe and never under its plaintext name. The
source scan and its table of computed names are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 17:09:20 +02:00
commit 48915317ef

View file

@ -1,7 +1,8 @@
import { readdirSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { appProfiles } from "../src/app-profiles.js";
import { appSlugs, providers } from "../src/contracts.js";
import { loadConfig } from "../src/config.js";
import { providerCatalog } from "../src/providers.js";
// The Idunn recipe decides which environment names a binding may set, and
@ -26,34 +27,40 @@ const everyDeclaredName = [...recipe.replace(/\r\n/g, "\n").matchAll(/^\w+_envir
(match) => [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!)
);
// The secrets Heimdall reads are whatever its source passes to
// readSecretInput, so the set is read from the source: a secret added there
// cannot be declared in plaintext here without failing. A literal name is
// taken as written; the two computed names expand over the lists that compute
// them; any other argument fails, because this test cannot know its names.
const srcRoot = new URL("../src/", import.meta.url);
const secretArguments = readdirSync(srcRoot, { recursive: true })
.map(String)
.filter((file) => file.endsWith(".ts"))
.flatMap((file) =>
[...readFileSync(new URL(file.replace(/\\/g, "/"), srcRoot), "utf8").matchAll(/(?<!function )readSecretInput\(\s*[^,()]+,\s*([^)]+?)\s*\)/g)].map(
(call) => call[1]!
)
);
const computedSecretNames: Record<string, string[]> = {
"`${prefix}_CLIENT_SECRET`": providers.map((provider) => `GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`),
envKey: appSlugs.map((appSlug) => `GC_ACCESS_APP_${appSlug.toUpperCase()}_SHARED_SECRET`),
};
// The secrets Heimdall reads are observed, not read from the source: the real
// loadConfig runs against an environment that records every name it looks
// up. readSecretInput looks up `NAME_FILE` for each secret, so every `_FILE`
// lookup names one, however the call is spelled and whatever list computes
// the name. The same recorder stands in for process.env during the call, so a
// read that bypasses the env argument is seen too. The environment is empty,
// so this sees the reads loadConfig makes when nothing is set; a secret read
// only on a branch some other variable opens would not be seen.
function namesLoadConfigReads(): Set<string> {
const names = new Set<string>();
const env = new Proxy({} as NodeJS.ProcessEnv, {
get(_target, name) {
if (typeof name === "string") names.add(name);
return undefined;
},
has(_target, name) {
if (typeof name === "string") names.add(name);
return false;
},
});
const processEnv = process.env;
process.env = env;
try {
loadConfig(env, []);
} finally {
process.env = processEnv;
}
return names;
}
/** Secrets Heimdall reads through readSecretInput, by their plaintext name. */
const secretNames = secretArguments.flatMap((argument) => {
const literal = /^"([A-Z0-9_]+)"$/.exec(argument);
if (literal) return [literal[1]!];
const computed = computedSecretNames[argument];
if (computed) return computed;
throw new Error(`recipe-secrets cannot derive the names read by readSecretInput(env, ${argument}); teach it.`);
});
const secretNames = [...namesLoadConfigReads()]
.filter((name) => name.endsWith("_FILE"))
.map((name) => name.slice(0, -"_FILE".length));
const secretShaped =
/SECRET|PASSWORD|PASSPHRASE|TOKEN|API_KEY|PRIVATE_KEY|ENCRYPTION_KEY|SALT|CREDENTIAL|DATABASE_URL|_DSN|_PEM/;
@ -79,14 +86,14 @@ describe("Idunn recipe secret declarations", () => {
expect(plaintext).toEqual([]);
});
it("finds every readSecretInput call in the source", () => {
it("sees every secret loadConfig reads, across the provider and app lists", () => {
expect(secretNames).toEqual(
expect.arrayContaining([
"GC_ACCESS_DATABASE_URL",
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64",
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET",
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",
"GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET",
...providers.map((provider) => `GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`),
...appSlugs.map((appSlug) => `GC_ACCESS_APP_${appSlug.toUpperCase()}_SHARED_SECRET`),
])
);
});