fix(javascript): retain Electron switch conditions (#7910)
* fix(javascript): retain Electron switch conditions * fix(javascript): bound switch path traversal
This commit is contained in:
1 parent
086abf3996
commit
844bb46351
4 files changed
+259
-1
No files matched your search
@@ -560,6 +560,8 @@ alternative object. A traced invocation whose key, API object or world stays
|
||||
unresolved or guarded keeps the call's own unresolved observation.
|
||||
Unknown spreads, reassigned bindings, accessors,
|
||||
dynamic channels and unsupported syntax do not become exact implementations.
|
||||
Literal switch cases retain their selection conditions. Possible fallthrough
|
||||
remains partial; dynamic case expressions remain unknown.
|
||||
This pass neither executes the bundle nor proves that runtime guards pass.
|
||||
An ambiguous or unresolved handler retains its channel registration but does
|
||||
not acquire a path to one arbitrarily selected callback. Unknown factory
|
||||
|
||||
@@ -1,6 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
it("retains switch selection on a direct bridge exposure", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
|
||||
}
|
||||
`);
|
||||
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
|
||||
expect.objectContaining({
|
||||
expression: '((process.platform) === ("win32"))',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains a conservative selection for stacked and fallthrough cases", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
switch (platform) {
|
||||
case "first": work();
|
||||
case "second":
|
||||
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
|
||||
}
|
||||
`);
|
||||
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
|
||||
expect.objectContaining({
|
||||
expression: '((platform) === ("first")) || ((platform) === ("second"))',
|
||||
}),
|
||||
]);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
|
||||
it("includes the default selection when an earlier default can fall through", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
switch (platform) {
|
||||
default: work();
|
||||
case "second":
|
||||
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
|
||||
}
|
||||
`);
|
||||
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
|
||||
expect.objectContaining({
|
||||
expression: '((platform) === ("second")) || ((platform) !== ("second"))',
|
||||
}),
|
||||
]);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
|
||||
it("retains switch conditions in a traced bridge wrapper", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
contextBridge.exposeInMainWorld("api", {
|
||||
ping() { switch (process.platform) { case "win32": return ipcRenderer.invoke("ping"); } }
|
||||
});
|
||||
`);
|
||||
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
|
||||
expect.objectContaining({
|
||||
expression: '((process.platform) === ("win32"))',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["direct", "wrapped"] as const)(
|
||||
"retains the selected case near the end of a switch on a %s path",
|
||||
(kind) => {
|
||||
const exposure = `contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });`;
|
||||
const cases = Array.from(
|
||||
{ length: 8 },
|
||||
(_, index) => `case ${index}:`,
|
||||
).join("\n");
|
||||
const selected = `${cases}\ncase 8: ${kind === "direct" ? exposure : 'return ipcRenderer.invoke("ping");'}`;
|
||||
const body =
|
||||
kind === "direct"
|
||||
? `switch (platform) { ${selected} }`
|
||||
: `contextBridge.exposeInMainWorld("api", { ping() { switch (platform) { ${selected} } } });`;
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
${body}
|
||||
`);
|
||||
expect(
|
||||
result.electron.call_paths?.[0]?.conditions[0]?.expression,
|
||||
).toContain("((platform) === (8))");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not invent a selection predicate for dynamic case expressions", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
switch (platform) {
|
||||
case mutatePlatform():
|
||||
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
|
||||
}
|
||||
`);
|
||||
expect(result.electron.call_paths?.[0]?.conditions).toEqual([]);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
|
||||
it("retains the continuing predicate after a break to an enclosing label", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`const e=require("electron");
|
||||
guard: {
|
||||
|
||||
@@ -302,6 +302,92 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
],
|
||||
};
|
||||
};
|
||||
/** A case can be reached by its own selection or by fallthrough from an earlier case. */
|
||||
const switchCaseContext = (
|
||||
node: t.SwitchStatement,
|
||||
index: number,
|
||||
context: PathContext,
|
||||
): PathContext => {
|
||||
const text = (part: t.Node): string | undefined =>
|
||||
part.start == null || part.end == null || part.end - part.start > 512
|
||||
? undefined
|
||||
: source.slice(part.start, part.end);
|
||||
const discriminant = text(node.discriminant);
|
||||
if (discriminant === undefined) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return context;
|
||||
}
|
||||
const choices: string[] = [];
|
||||
let expressionBytes = 0;
|
||||
let defaultBefore = false;
|
||||
const compare = (test: t.Expression, operator: "===" | "!==") => {
|
||||
// Dynamic case expressions can mutate the evaluated discriminant; do
|
||||
// not turn a second source evaluation into an invented predicate.
|
||||
if (
|
||||
!t.isStringLiteral(test) &&
|
||||
!t.isNumericLiteral(test) &&
|
||||
!t.isBooleanLiteral(test) &&
|
||||
!t.isNullLiteral(test) &&
|
||||
!t.isBigIntLiteral(test)
|
||||
)
|
||||
return undefined;
|
||||
const label = text(test);
|
||||
return label === undefined
|
||||
? undefined
|
||||
: `(${discriminant}) ${operator} (${label})`;
|
||||
};
|
||||
const append = (output: string[], item: string): boolean => {
|
||||
expressionBytes += item.length + 6;
|
||||
if (expressionBytes > 512) return false;
|
||||
output.push(item);
|
||||
return true;
|
||||
};
|
||||
let fallthrough = false;
|
||||
for (let i = 0; i <= index; i += 1) {
|
||||
if (--remainingVisits < 0) return context;
|
||||
const branch = node.cases[i];
|
||||
if (!branch) continue;
|
||||
if (i < index && branch.consequent.length > 0) fallthrough = true;
|
||||
if (branch.test == null) {
|
||||
defaultBefore = true;
|
||||
continue;
|
||||
}
|
||||
const match = compare(branch.test, "===");
|
||||
if (match === undefined || !append(choices, match)) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return context;
|
||||
}
|
||||
}
|
||||
if (defaultBefore) {
|
||||
const unmatched: string[] = [];
|
||||
for (const branch of node.cases) {
|
||||
if (--remainingVisits < 0) return context;
|
||||
if (branch.test == null) continue;
|
||||
const mismatch = compare(branch.test, "!==");
|
||||
if (mismatch === undefined || !append(unmatched, mismatch)) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return context;
|
||||
}
|
||||
}
|
||||
choices.push(unmatched.length === 0 ? "true" : unmatched.join(" && "));
|
||||
}
|
||||
// Earlier case bodies may terminate or change state before reaching this
|
||||
// case. The selection disjunction is necessary but not a complete path.
|
||||
if (fallthrough) accumulator.unknownFindings += 1;
|
||||
const expression = choices.map((choice) => `(${choice})`).join(" || ");
|
||||
if (expression.length > 512) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return context;
|
||||
}
|
||||
if (expression === "") return context;
|
||||
return {
|
||||
...context,
|
||||
conditions: [
|
||||
...context.conditions,
|
||||
{ expression, location: range(node) },
|
||||
],
|
||||
};
|
||||
};
|
||||
const sequentialFallthrough = (
|
||||
fact: ElectronStatementFallthrough | null,
|
||||
env: SymbolicEnvironment,
|
||||
@@ -1172,6 +1258,7 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
let next = context;
|
||||
for (const statement of node.body) {
|
||||
walk(statement, env, next);
|
||||
if (remainingVisits < 0) return;
|
||||
const fallthrough = sequentialFallthrough(
|
||||
prefixes.statement(statement, node) ?? null,
|
||||
env,
|
||||
@@ -1218,9 +1305,27 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (t.isSwitchStatement(node)) {
|
||||
walk(node.discriminant, env, context);
|
||||
for (let index = 0; index < node.cases.length; index += 1) {
|
||||
if (remainingVisits < 0) return;
|
||||
const branch = node.cases[index]!;
|
||||
if (branch.test) walk(branch.test, env, context);
|
||||
if (remainingVisits < 0) return;
|
||||
const branchContext = switchCaseContext(node, index, context);
|
||||
for (const statement of branch.consequent) {
|
||||
if (remainingVisits < 0) return;
|
||||
walk(statement, env, branchContext);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
|
||||
call(node, env, context);
|
||||
for (const child of childNodes(node)) walk(child, env, context);
|
||||
for (const child of childNodes(node)) {
|
||||
if (remainingVisits < 0) return;
|
||||
walk(child, env, context);
|
||||
}
|
||||
}
|
||||
let prefixVisits = 0;
|
||||
function* seed(
|
||||
@@ -1257,6 +1362,18 @@ export function* collectElectronWrappedCallPathsSteps(
|
||||
if (truth !== undefined && truth !== positive) return;
|
||||
if (truth === undefined)
|
||||
context = condition(context, parent.test, positive);
|
||||
} else if (t.isSwitchCase(parent) && child !== parent.test) {
|
||||
const owner = state.parentsByNode.get(parent);
|
||||
if (owner && t.isSwitchStatement(owner)) {
|
||||
let index = 0;
|
||||
while (index < owner.cases.length && owner.cases[index] !== parent) {
|
||||
if (--remainingVisits < 0) return;
|
||||
index += 1;
|
||||
}
|
||||
if (index < owner.cases.length)
|
||||
context = switchCaseContext(owner, index, context);
|
||||
else accumulator.unknownFindings += 1;
|
||||
} else accumulator.unknownFindings += 1;
|
||||
} else if (t.isLogicalExpression(parent) && child === parent.right) {
|
||||
const right = symbolicLogicalRight(
|
||||
parent.operator,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"retains a switch selection on Electron bridge paths through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-switch-");
|
||||
await writeFixtureFiles(root, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "switch",
|
||||
version: "1",
|
||||
main: "main.js",
|
||||
}),
|
||||
"main.js": `const { BrowserWindow, ipcMain } = require("electron");
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
new BrowserWindow({ webPreferences: { preload: "preload.js" } });`,
|
||||
"preload.js": `const { contextBridge, ipcRenderer } = require("electron");
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
|
||||
}`,
|
||||
});
|
||||
const result = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
const edges = result.graph.edges.filter(
|
||||
(edge) =>
|
||||
edge.evidence.extractor.operation === "trace-electron-wrapper-path",
|
||||
);
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
for (const edge of edges)
|
||||
expect(edge.properties.conditions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
expression: '((process.platform) === ("win32"))',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user