Files
software-mansion__argent/packages/tool-server/test/flows/script/flow-script-redaction.test.ts
2026-09-11 11:40:54 +02:00

455 lines
16 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
FlowScriptExecutor,
type FlowScriptExecutorOptions,
type FlowScriptSecret,
} from "../../../src/tools/flows/script/flow-script-executor";
import { SCRIPT_MAX_FAILURE_MESSAGE_CHARS } from "../../../src/tools/flows/script/flow-script-protocol";
import { resolveHostBash } from "../../helpers/host-bash";
import { createScriptWorkspace, type ScriptWorkspace } from "../../helpers/flow-script-workspace";
const workspaces: ScriptWorkspace[] = [];
function workspace(): ScriptWorkspace {
const ws = createScriptWorkspace("redact");
workspaces.push(ws);
return ws;
}
const longRoots: string[] = [];
afterEach(() => {
while (workspaces.length) workspaces.pop()!.cleanup();
while (longRoots.length) fs.rmSync(longRoots.pop()!, { recursive: true, force: true });
});
/**
* A second name for the host's bash, of a chosen length. The resolver reports
* `scripts.bash` verbatim, so the length of the configured value is the length
* of what rides in the exit line of every failure message. Nested directories
* rather than one long name, because a single path component is capped at 255
* bytes.
*/
function bashAtPathOfLength(bash: string, chars: number): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "argent-long-bash-"));
longRoots.push(root);
let dir = root;
while (chars - dir.length - 1 > 255) dir = path.join(dir, "d".repeat(200));
const link = path.join(dir, "b".repeat(chars - dir.length - 1));
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(bash, link);
return link;
}
async function withPinnedBash<T>(bash: string, body: () => Promise<T>): Promise<T> {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "argent-redaction-home-"));
const real = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
fs.mkdirSync(path.join(home, ".argent"), { recursive: true });
fs.writeFileSync(
path.join(home, ".argent", "config.json"),
JSON.stringify({ scripts: { bash } }),
"utf8"
);
process.env.HOME = home;
process.env.USERPROFILE = home;
try {
return await body();
} finally {
for (const [name, previous] of Object.entries(real)) {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
fs.rmSync(home, { recursive: true, force: true });
}
}
function executor(options: FlowScriptExecutorOptions = {}) {
return new FlowScriptExecutor({ concurrency: 4, maxTimeoutMs: 60_000, ...options });
}
describe("flow script executor — redaction of a bash step", () => {
const SECRET: FlowScriptSecret = { name: "API_KEY", value: "s3cr3t-token-value" };
const STDERR_LINE_CHARS = 1_000;
let noBash: string | undefined;
let hostBash = "";
beforeAll(async () => {
const found = await resolveHostBash();
if ("path" in found) hostBash = found.path;
else noBash = found.problem;
});
beforeEach((ctx) => {
if (noBash) ctx.skip(`this host has no bash to run a .sh step with: ${noBash}`);
});
it("replaces a secret the script wrote to stderr, in the reason and in the log", async () => {
const ws = workspace();
const script = ws.write(
"reason.sh",
`printf 'the call to %s failed\\n' "$API_KEY" >&2
exit 4`
);
const result = await executor().execute({
scriptPath: script,
interpreter: "bash",
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.failure?.kind).toBe("exit");
expect(result.failure?.message).not.toContain(SECRET.value);
expect(result.failure?.message).toContain("the call to {{secret:API_KEY}} failed");
expect(result.log).not.toContain(SECRET.value);
expect(result.log).toContain("the call to {{secret:API_KEY}} failed");
}, 30_000);
it("replaces a secret the script wrote into its output document", async () => {
const ws = workspace();
const script = ws.write(
"document.sh",
`printf '{"auth":"Bearer %s"}' "$API_KEY" > "$ARGENT_OUTPUT.t"
mv "$ARGENT_OUTPUT.t" "$ARGENT_OUTPUT"`
);
const result = await executor().execute({
scriptPath: script,
interpreter: "bash",
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.ok).toBe(true);
expect(JSON.stringify(result.output)).not.toContain(SECRET.value);
expect(JSON.stringify(result.output)).toContain("API_KEY");
}, 30_000);
it("drops the half of a secret the stderr line's cut left behind", async () => {
const ws = workspace();
const pad = STDERR_LINE_CHARS - 10;
const tail = 2_000;
const script = ws.write(
"long-line.sh",
`printf '%${pad}s' '' | tr ' ' 'x' >&2
printf '%s' "$API_KEY" >&2
printf '%${tail}s' '' | tr ' ' 'y' >&2
echo >&2
exit 5`
);
const result = await executor().execute({
scriptPath: script,
interpreter: "bash",
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
const message = result.failure?.message ?? "";
expect(result.failure?.kind).toBe("exit");
for (let n = SECRET.value.length; n > 3; n -= 1) {
expect(message).not.toContain(SECRET.value.slice(0, n));
}
const marker = `x… [${SECRET.value.length + tail} more characters omitted]`;
expect(message.slice(-marker.length)).toBe(marker);
expect(result.log).not.toContain(SECRET.value);
expect(result.log).toContain("x{{secret:API_KEY}}y");
}, 30_000);
/**
* The interpreter path rides in the exit line in front of the stderr line,
* and it is the one term in the message nothing bounds. A ceiling it can push
* the message past cuts the line's own marker off the end — the marker the
* parent reads to find where the line was cut, and so where half a secret
* may be left. This is the same straddling secret as above, under a bash
* whose path is as long as the whole line the reason keeps: the marker at
* the end must still be the line's own, counting the half it dropped.
*
* POSIX only. The path is a symlink of 1,000 characters, and Windows refuses
* both without a per-machine opt-in.
*/
it.skipIf(process.platform === "win32")(
"keeps the stderr line's own marker when the interpreter path is long",
async () => {
const ws = workspace();
const pinned = bashAtPathOfLength(hostBash, STDERR_LINE_CHARS);
const pad = STDERR_LINE_CHARS - 10;
const tail = 2_000;
const script = ws.write(
"long-line-long-bash.sh",
`printf '%${pad}s' '' | tr ' ' 'x' >&2
printf '%s' "$API_KEY" >&2
printf '%${tail}s' '' | tr ' ' 'y' >&2
echo >&2
exit 5`
);
const result = await withPinnedBash(pinned, () =>
executor().execute({
scriptPath: script,
interpreter: "bash",
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
})
);
const message = result.failure?.message ?? "";
expect(result.failure?.kind).toBe("exit");
expect(message).toContain(pinned);
expect(message.length).toBeLessThanOrEqual(SCRIPT_MAX_FAILURE_MESSAGE_CHARS);
const marker = `x… [${SECRET.value.length + tail} more characters omitted]`;
expect(message.slice(-marker.length)).toBe(marker);
for (let n = SECRET.value.length; n > 3; n -= 1) {
expect(message).not.toContain(SECRET.value.slice(0, n));
}
},
30_000
);
});
describe("flow script executor — the heap verdict", () => {
it("recognises a heap banner split across two pipe chunks", async () => {
const ws = workspace();
const script = ws.write(
"split-heap.mjs",
`const wait = (ms) => new Promise((r) => setTimeout(r, ms));
process.stderr.write("\\n<--- Last few GCs --->\\n\\nFATAL ERROR: Reached ");
await wait(60);
process.stderr.write("heap limit Allocation failed - JavaScript heap out of memory\\n");
await wait(60);
process.abort();`
);
const result = await executor({ heapLimitMb: 64 }).execute({
scriptPath: script,
projectRoot: ws.dir,
timeoutMs: 20_000,
});
expect(result.failure?.kind).toBe("heap");
expect(result.failure?.message).toContain("64 MiB");
}, 30_000);
});
describe("flow script executor — redaction", () => {
const SECRET: FlowScriptSecret = { name: "API_KEY", value: "s3cr3t-token-value" };
it("replaces a secret written in one piece", async () => {
const ws = workspace();
const script = ws.write("plain.mjs", `output.auth = "auth: " + process.env.API_KEY;`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.output).toEqual({ auth: "auth: {{secret:API_KEY}}" });
});
it("replaces a secret in the failure message and its stack", async () => {
const ws = workspace();
const script = ws.write(
"assert.mjs",
`import assert from "node:assert/strict";
assert.equal("sk-live-WRONG", process.env.API_KEY);`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.failure?.kind).toBe("runtime");
expect(result.failure?.message).not.toContain(SECRET.value);
expect(result.failure?.message).toContain("{{secret:API_KEY}}");
expect(result.failure?.stack).not.toContain(SECRET.value);
});
it("replaces a secret in the output document, at any depth and in a key", async () => {
const ws = workspace();
const script = ws.write(
"echo.mjs",
`const key = process.env.API_KEY;
output.session = { token: key, scopes: ["read", key] };
output[key] = "keyed";`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.ok).toBe(true);
expect(JSON.stringify(result.output)).not.toContain(SECRET.value);
expect(result.output).toEqual({
"session": { token: "{{secret:API_KEY}}", scopes: ["read", "{{secret:API_KEY}}"] },
"{{secret:API_KEY}}": "keyed",
});
});
it("leaves a marker well formed when a value occurs inside another secret's name", async () => {
const ws = workspace();
const script = ws.write("marker.mjs", `output.line = "value=Q";`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [{ name: "Q0", value: "Q" }],
});
expect(result.output).toEqual({ line: "value={{secret:Q0}}" });
});
it("leaves a marker well formed when two secrets swap name and value", async () => {
const ws = workspace();
const script = ws.write("swapped.mjs", `output.line = "id=TOKEN_ABC and OKEN";`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [
{ name: "TOKEN_ABC", value: "OKEN" },
{ name: "OKEN", value: "TOKEN_ABC" },
],
});
expect(result.output).toEqual({ line: "id={{secret:OKEN}} and {{secret:TOKEN_ABC}}" });
});
it("refuses a document whose redacted key would replace a sibling", async () => {
const ws = workspace();
const script = ws.write("collide.mjs", `output.doc = { "ab": 1, "{{secret:s}}": 2 };`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [{ name: "s", value: "ab" }],
});
expect(result.ok).toBe(false);
expect(result.failure?.kind).toBe("output");
expect(result.failure?.message).toContain("Two keys in the script's output become \"");
expect(result.failure?.message).toContain("{{secret:s}}");
expect(result.output).toBeUndefined();
});
it("replaces a value that starts inside marker-shaped text the script wrote", async () => {
const ws = workspace();
const script = ws.write("echoed.mjs", `output.line = "head {{secret:TOK}}TAIL tail";`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [
{ name: "TOK", value: "tok" },
{ name: "V", value: "TOK}}TAIL" },
],
});
const line = (result.output as { line: string }).line;
expect(line).not.toContain("TOK}}TAIL");
expect(line).toContain("{{secret:V}}");
});
it("replaces a nested secret as part of the value around it, and alone elsewhere", async () => {
const ws = workspace();
const host: FlowScriptSecret = { name: "HOST", value: "api.internal.example.com" };
const url: FlowScriptSecret = {
name: "URL",
value: `https://${host.value}/tenant/9f3a0b1c2d3e4f50`,
};
const script = ws.write(
"nested.mjs",
`output.line =
"calling " + ${JSON.stringify(url.value)} + " on " + ${JSON.stringify(host.value)};`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [host, url],
});
expect(result.output).toEqual({ line: "calling {{secret:URL}} on {{secret:HOST}}" });
});
it("replaces the longer of two secrets when the shorter one is its prefix", async () => {
const ws = workspace();
const prefix: FlowScriptSecret = { name: "PFX", value: "sk-" };
const full: FlowScriptSecret = { name: "FULL", value: "sk-live-9d3f0a1b" };
const script = ws.write("prefix.mjs", `output.line = "tok sk-live-9d3f0a1b end";`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [prefix, full],
});
expect(result.output).toEqual({ line: "tok {{secret:FULL}} end" });
});
it("replaces every occurrence of a value that starts with its own tail", async () => {
const ws = workspace();
const value = "0123456789".repeat(4);
const script = ws.write("periodic.mjs", `output.blob = ${JSON.stringify(value)}.repeat(128);`);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
secrets: [{ name: "P", value }],
});
const blob = (result.output as { blob: string }).blob;
expect(blob).not.toMatch(/[0-9]/);
expect(blob).toContain("{{secret:P}}");
}, 30_000);
it("keeps a secret that straddles the failure-message ceiling out of the report", async () => {
const ws = workspace();
const script = ws.write(
"long-throw.mjs",
`throw new Error(
"p".repeat(${SCRIPT_MAX_FAILURE_MESSAGE_CHARS} - 41) + process.env.API_KEY + "t".repeat(1000)
);`
);
const result = await executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { API_KEY: SECRET.value },
secrets: [SECRET],
});
expect(result.failure?.kind).toBe("runtime");
expect(result.failure?.message).not.toContain(SECRET.value.slice(0, 8));
expect(result.failure?.message).toMatch(/… \[\d+ more characters omitted]$/);
expect(result.failure?.stack).not.toContain(SECRET.value.slice(0, 8));
});
it("reads the secret set live, so a value added mid-run still redacts", async () => {
const ws = workspace();
const script = ws.write(
"later.mjs",
`output.first = process.env.EARLY;
await new Promise((r) => setTimeout(r, 150));
output.second = process.env.LATE;`
);
const secrets: FlowScriptSecret[] = [{ name: "EARLY", value: "early-value-aaaa" }];
const pending = executor().execute({
scriptPath: script,
projectRoot: ws.dir,
env: { EARLY: "early-value-aaaa", LATE: "late-value-bbbb" },
secrets,
});
// Pushed from a later turn of the loop, after the step has read the set
// once: pushing in the same turn as the call lands before `runOne` ever
// looks, so an implementation that snapshotted the array once would pass.
setTimeout(() => secrets.push({ name: "LATE", value: "late-value-bbbb" }), 60);
const result = await pending;
expect(result.output).toEqual({
first: "{{secret:EARLY}}",
second: "{{secret:LATE}}",
});
});
});