mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
455 lines
16 KiB
TypeScript
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}}",
|
|
});
|
|
});
|
|
});
|