2026-10-03 23:22:07 +08:00
|
|
|
import { describe, expect, it } from "vitest";
|
2026-07-29 22:00:38 +00:00
|
|
|
|
2026-08-10 14:57:44 +08:00
|
|
|
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
2026-07-29 22:00:38 +00:00
|
|
|
|
|
|
|
|
describe("JavaScript semantic analysis: native bindings 1", () => {
|
|
|
|
|
it("recovers child-process argv, env, stdio, listeners, and signals", () => {
|
|
|
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
|
|
|
import { spawn as launch } from "node:child_process";
|
|
|
|
|
function run() {
|
|
|
|
|
const child = launch(
|
|
|
|
|
"/bin/tool",
|
|
|
|
|
["--mode", "fast"],
|
|
|
|
|
{ env: { MODE: "test" }, stdio: ["ignore", "pipe", "pipe"] },
|
|
|
|
|
);
|
|
|
|
|
child.on("exit", onExit);
|
|
|
|
|
child.once("error", onError);
|
|
|
|
|
child.kill("SIGINT");
|
|
|
|
|
}
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
expect(ir.childProcessSpawns).toEqual([
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
method: "spawn",
|
|
|
|
|
command: "/bin/tool",
|
|
|
|
|
argvCount: 2,
|
|
|
|
|
environmentSupplied: true,
|
|
|
|
|
stdioMode: "array",
|
|
|
|
|
resolution: "complete",
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
expect(
|
|
|
|
|
ir.childProcessInteractions.map(
|
|
|
|
|
({ kind, eventName, signalName, resolution }) => ({
|
|
|
|
|
kind,
|
|
|
|
|
eventName,
|
|
|
|
|
signalName,
|
|
|
|
|
resolution,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
).toEqual([
|
|
|
|
|
{
|
|
|
|
|
kind: "listener",
|
|
|
|
|
eventName: "exit",
|
|
|
|
|
signalName: null,
|
|
|
|
|
resolution: "complete",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "listener",
|
|
|
|
|
eventName: "error",
|
|
|
|
|
signalName: null,
|
|
|
|
|
resolution: "complete",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "signal",
|
|
|
|
|
eventName: null,
|
|
|
|
|
signalName: "SIGINT",
|
|
|
|
|
resolution: "complete",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
expect(ir.functionFingerprints[0]?.components.effects).toContain(
|
|
|
|
|
"child-process",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("recovers configuration, requests, response consumers, and boundaries", () => {
|
|
|
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import * as http from "node:http";
|
|
|
|
|
async function run(schema) {
|
|
|
|
|
const endpoint = process.env.API_URL ?? "https://fallback.test";
|
|
|
|
|
const mode = process.argv[2];
|
|
|
|
|
const raw = readFileSync("./config.json", "utf8");
|
|
|
|
|
const parsed = JSON.parse(raw);
|
|
|
|
|
const port = Number(process.env.PORT);
|
|
|
|
|
const validated = schema.parse(parsed);
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: validated,
|
|
|
|
|
});
|
|
|
|
|
const body = await response.json();
|
|
|
|
|
http.request("https://audit.test", { body });
|
|
|
|
|
return { mode, port, body };
|
|
|
|
|
}
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
ir.configurationOperations.map(({ kind, key }) => ({ kind, key })),
|
|
|
|
|
).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
{ kind: "environment", key: "API_URL" },
|
|
|
|
|
{ kind: "default", key: "API_URL" },
|
|
|
|
|
{ kind: "argv", key: "2" },
|
|
|
|
|
{ kind: "file", key: "./config.json" },
|
|
|
|
|
{ kind: "environment", key: "PORT" },
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
ir.requestOperations.map(({ kind, method, endpoint, resolution }) => ({
|
|
|
|
|
kind,
|
|
|
|
|
method,
|
|
|
|
|
endpoint,
|
|
|
|
|
resolution,
|
|
|
|
|
})),
|
|
|
|
|
).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
{
|
|
|
|
|
kind: "request",
|
|
|
|
|
method: "fetch",
|
|
|
|
|
endpoint: null,
|
|
|
|
|
resolution: "partial",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "response-consumer",
|
|
|
|
|
method: "json",
|
|
|
|
|
endpoint: null,
|
|
|
|
|
resolution: "complete",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "request",
|
|
|
|
|
method: "request",
|
|
|
|
|
endpoint: "https://audit.test",
|
|
|
|
|
resolution: "complete",
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
ir.boundaryOperations.map(({ kind, method, resolution }) => ({
|
|
|
|
|
kind,
|
|
|
|
|
method,
|
|
|
|
|
resolution,
|
|
|
|
|
})),
|
|
|
|
|
).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
{ kind: "parse", method: "JSON.parse", resolution: "complete" },
|
|
|
|
|
{ kind: "coerce", method: "Number", resolution: "complete" },
|
|
|
|
|
{ kind: "parse", method: "parse", resolution: "partial" },
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
expect(ir.functionFingerprints[0]?.components.effects).toContain("network");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("JavaScript semantic analysis: native bindings 2", () => {
|
|
|
|
|
it("recovers built-in resource acquisition and exact local release", () => {
|
|
|
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
|
|
|
import { open } from "node:fs/promises";
|
|
|
|
|
import { connect as dial } from "node:net";
|
|
|
|
|
async function run(path) {
|
|
|
|
|
const file = await open(path);
|
|
|
|
|
const socket = dial({ port: 9000 });
|
|
|
|
|
await file.close();
|
|
|
|
|
socket.destroy();
|
|
|
|
|
}
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
ir.resourceOperations.map(({ kind, method, resolution }) => ({
|
|
|
|
|
kind,
|
|
|
|
|
method,
|
|
|
|
|
resolution,
|
|
|
|
|
})),
|
|
|
|
|
).toEqual([
|
|
|
|
|
{ kind: "acquire", method: "open", resolution: "complete" },
|
|
|
|
|
{ kind: "acquire", method: "connect", resolution: "complete" },
|
|
|
|
|
{ kind: "release", method: "close", resolution: "complete" },
|
|
|
|
|
{ kind: "release", method: "destroy", resolution: "complete" },
|
|
|
|
|
]);
|
|
|
|
|
expect(ir.functionFingerprints[0]?.components.effects).toContain(
|
|
|
|
|
"resource",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-14 08:44:52 +08:00
|
|
|
|
|
|
|
|
describe("JavaScript semantic analysis: child-process signals", () => {
|
|
|
|
|
it("keeps unresolved kill signals distinct from default and literal signals", () => {
|
|
|
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
|
|
|
import { spawn } from "node:child_process";
|
|
|
|
|
function run(signal) {
|
|
|
|
|
const child = spawn("/bin/tool");
|
|
|
|
|
child.kill(signal);
|
|
|
|
|
child.kill();
|
|
|
|
|
child.kill("SIGINT");
|
|
|
|
|
child.kill(undefined);
|
|
|
|
|
child.kill(undefined as NodeJS.Signals | undefined);
|
|
|
|
|
child.kill(void 0);
|
|
|
|
|
child.kill(void signal);
|
|
|
|
|
}
|
|
|
|
|
function shadowed(undefined) {
|
|
|
|
|
const child = spawn("/bin/tool");
|
|
|
|
|
child.kill(undefined);
|
|
|
|
|
}
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
expect(ir.childProcessSpawns).toHaveLength(2);
|
|
|
|
|
expect(
|
|
|
|
|
ir.childProcessInteractions.map(({ signalName }) => signalName),
|
|
|
|
|
).toEqual([
|
|
|
|
|
null,
|
|
|
|
|
"SIGTERM",
|
|
|
|
|
"SIGINT",
|
|
|
|
|
"SIGTERM",
|
|
|
|
|
"SIGTERM",
|
|
|
|
|
"SIGTERM",
|
|
|
|
|
"SIGTERM",
|
|
|
|
|
null,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-13 21:17:54 -07:00
|
|
|
|
|
|
|
|
describe("JavaScript semantic analysis: configuration value escapes", () => {
|
|
|
|
|
const configurationReads = (body: string) =>
|
|
|
|
|
analyzeJavaScriptSemantics(`function run() { ${body} }`)
|
|
|
|
|
.configurationOperations.filter(({ kind }) => kind !== "default")
|
|
|
|
|
.map(({ kind, key }) => [kind, key]);
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
[
|
|
|
|
|
"an environment value",
|
|
|
|
|
"return Number(process.env.PORT);",
|
|
|
|
|
"environment",
|
|
|
|
|
"PORT",
|
|
|
|
|
],
|
|
|
|
|
["an argv entry", "return Number(process.argv[2]);", "argv", "2"],
|
|
|
|
|
])("keeps a read after passing %s to a call", (_name, body, kind, key) => {
|
|
|
|
|
expect(configurationReads(body)).toEqual([[kind, key]]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-15 02:28:46 +08:00
|
|
|
it.each([
|
|
|
|
|
"patch(process.argv[key]); return process.argv[3];",
|
|
|
|
|
"patch(process.argv.__proto__); return process.argv[3];",
|
|
|
|
|
"const args = process.argv; patch(args[key]); return args[3];",
|
|
|
|
|
])(
|
|
|
|
|
"retains uncertainty after an object-valued argv selection escapes: %s",
|
|
|
|
|
(body) => {
|
|
|
|
|
expect(configurationReads(body)).not.toContainEqual(["argv", "3"]);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it("preserves an earlier top-level read and unrelated configuration after an argv escape", () => {
|
|
|
|
|
const captured = analyzeJavaScriptSemantics(
|
|
|
|
|
"const saved = process.argv[3]; patch(process.argv[key]); globalThis.saved = saved;",
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
captured.configurationOperations.map(({ kind, key }) => [kind, key]),
|
|
|
|
|
).toContainEqual(["argv", "3"]);
|
|
|
|
|
expect(
|
|
|
|
|
configurationReads("patch(process.argv[key]); return process.env.PORT;"),
|
|
|
|
|
).toContainEqual(["environment", "PORT"]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-13 21:17:54 -07:00
|
|
|
it.each([
|
|
|
|
|
[
|
|
|
|
|
"a direct argv entry",
|
|
|
|
|
"process.argv[2] = {}; return Number(process.argv[2]);",
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
"an aliased argv entry",
|
|
|
|
|
"const argv = process.argv; argv[2] = {}; return Number(process.argv[2]);",
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
"the argv container",
|
|
|
|
|
"mutate(process.argv); return Number(process.argv[2]);",
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
"the env container",
|
|
|
|
|
"mutate(process.env); return Number(process.env.PORT);",
|
|
|
|
|
],
|
|
|
|
|
])("leaves a read unknown after replacing %s", (_name, body) => {
|
|
|
|
|
expect(configurationReads(body)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|