fix(javascript): qualify network receiver evidence

This commit is contained in:
morluto
2026-10-27 09:24:19 +08:00
parent a293738c66
commit 25b69bb803
9 changed files with 323 additions and 60 deletions
@@ -0,0 +1,115 @@
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import {
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js";
import {
resolveSemanticBindingState,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import * as t from "@babel/types";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
export const isFetchCall = (
node: t.Node,
bindings: JavaScriptSemanticAnalysisState,
): boolean =>
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
isUnmodifiedGlobalReference(node.callee, bindings, "fetch");
/** Browser request identity requires an intact intrinsic, not a method spelling. */
export const isBrowserNetworkRequest = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
bindings: JavaScriptSemanticAnalysisState,
): boolean =>
isFetchCall(node, bindings) ||
(t.isNewExpression(node) &&
(isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") ||
isUnmodifiedGlobalReference(node.callee, bindings, "EventSource"))) ||
((t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
isUnmodifiedGlobalReference(node.callee, bindings, [
"navigator",
"sendBeacon",
]));
const isXhrOpenRequest = (
node: t.Node,
bindings: JavaScriptSemanticAnalysisState,
index?: Pick<JavaScriptBindingIndex, "referenceMayHaveChangedAt">,
): boolean | undefined => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return false;
const callee = unwrapJavaScriptExpression(node.callee).node;
if (
(!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) ||
semanticStaticPropertyName(callee.property, callee.computed) !== "open"
)
return false;
if (
["XMLHttpRequest", "Object"].some((owner) =>
["open", "__proto__"].some(
(member) =>
bindings.ambientWrites.covers([
"global",
owner,
"prototype",
member,
]) ||
bindings.ambientEscapes.covers([
"global",
owner,
"prototype",
member,
]),
),
)
)
return false;
let receiver = unwrapJavaScriptExpression(callee.object).node;
const seen = new Set<string>();
while (t.isIdentifier(receiver)) {
if (
index !== undefined &&
(index.referenceMayHaveChangedAt(node, receiver, ["open"]) ||
index.referenceMayHaveChangedAt(node, receiver, ["__proto__"]))
)
return false;
const binding = resolveSemanticBindingState(
bindings,
receiver,
receiver.name,
);
if (binding === undefined || seen.has(binding.bindingId)) return false;
seen.add(binding.bindingId);
const initializer = semanticStableInitializer(binding, bindings);
if (
initializer === undefined ||
initializer.projection.length !== 0 ||
!semanticBindingAtRead(binding, receiver, bindings.parentsByNode)
.hasDefiniteInitializer
)
return false;
receiver = unwrapJavaScriptExpression(initializer.node).node;
}
if (
!t.isNewExpression(receiver) ||
!isUnmodifiedGlobalReference(receiver.callee, bindings, "XMLHttpRequest") ||
semanticOpaqueGlobalMayHaveChanged(callee, bindings)
)
return false;
return index === undefined && seen.size > 0 ? undefined : true;
};
/** Resolve once through the shared mutation index when lexical proof needs it. */
export const createXhrOpenRequestQuery =
(index: JavaScriptBindingIndex): ((node: t.Node) => boolean) =>
(node) => {
const proof = isXhrOpenRequest(node, index.bindings);
if (proof !== undefined) return proof;
completeSemanticSteps(index.indexMemberMutationFactsSteps());
return isXhrOpenRequest(node, index.bindings, index) === true;
};
@@ -1,3 +1,4 @@
import { createXhrOpenRequestQuery } from "./javascriptNetworkRequests.js";
import { uniqueSortedBy } from "../canonicalOrdering.js";
import * as t from "@babel/types";
import { JavaScriptRouteScan } from "./javascriptRouteScan.js";
@@ -114,6 +115,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
bindings,
);
const findings: JavaScriptFindingContext = {
xhrOpenRequest: createXhrOpenRequestQuery(index),
pathIntrinsics: createStaticPathIntrinsicQuery(index),
browserWindowReceiver: (node, site, suffix) =>
resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index),
@@ -357,8 +357,8 @@ it.each(expressionWrappers)(
)
.toEqual([
["local-window", "candidate"],
["/local-db", null],
["/local-cache", null],
["/local-db", "candidate"],
["/local-cache", "candidate"],
["changed-window", "candidate"],
["uncertain-window", "candidate"],
]);
@@ -1,3 +1,7 @@
import {
isBrowserNetworkRequest,
isFetchCall,
} from "./javascriptNetworkRequests.js";
import * as t from "@babel/types";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
@@ -491,13 +495,6 @@ export const indexVitePreloadDependencyTable = (
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
};
const isFetchCall = (
node: t.Node,
bindings: JavaScriptSemanticAnalysisState,
): boolean =>
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
isUnmodifiedGlobalReference(node.callee, bindings, "fetch");
const requestConstructorUrl = (
node: t.Node,
bindings: JavaScriptSemanticAnalysisState,
@@ -591,7 +588,10 @@ const inspectLiteralEndpointCall = (
kind: "network",
value: endpoint.value,
mechanism: `call:${name}`,
...(endpoint.kind === "candidate" ? { interpretation: "candidate" } : {}),
...(endpoint.kind === "candidate" &&
context.xhrOpenRequest?.(node) !== true
? { interpretation: "candidate" }
: {}),
});
};
@@ -611,7 +611,7 @@ const isKnownNetworkRequest = (
bindings: JavaScriptSemanticAnalysisState,
context: FindingContext,
): boolean => {
if (isFetchCall(node, bindings)) return true;
if (isBrowserNetworkRequest(node, bindings)) return true;
const callee = unwrapJavaScriptExpression(node.callee).node;
if (
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
@@ -620,19 +620,7 @@ const isKnownNetworkRequest = (
context.rendererLoad?.(node.callee, node) === true
)
return true;
if (
t.isNewExpression(node) &&
(isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") ||
isUnmodifiedGlobalReference(node.callee, bindings, "EventSource"))
)
return true;
return (
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
isUnmodifiedGlobalReference(node.callee, bindings, [
"navigator",
"sendBeacon",
])
);
return false;
};
/** Finish queued targets after shared member-mutation facts are indexed. */
@@ -675,10 +675,10 @@ export const endpointArgument = (
callee: t.Node,
receiverFact?: JavaScriptOpenReceiverFact,
): JavaScriptEndpointArgument | undefined => {
const literal = (
const candidate = (
value: string | undefined,
): JavaScriptEndpointArgument | undefined =>
value === undefined ? undefined : { kind: "endpoint", value };
value === undefined ? undefined : { kind: "candidate", value };
if (receiverFact === "window") return undefined;
if (
name === "fetch" ||
@@ -687,17 +687,11 @@ export const endpointArgument = (
name === "EventSource" ||
name.endsWith(".EventSource")
)
return literal(stringValue(args[0]));
return candidate(stringValue(args[0]));
if (name.endsWith(".open")) {
const value = xhrOpenUrl(name, args[0], args[1], receiverFact);
if (value === undefined) return undefined;
return {
kind:
stringValue(args[0]) !== undefined || isRequestUrlReference(value)
? "endpoint"
: "candidate",
value,
};
return { kind: "candidate", value };
}
const method = ["get", "post", "put", "patch", "delete", "request"].find(
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
@@ -717,17 +711,10 @@ export const endpointArgument = (
receiverFact === "extension-state")))
)
return undefined;
return {
kind:
method !== "get" && method !== "delete" && isRequestUrlReference(value)
? "endpoint"
: "candidate",
value,
};
return { kind: "candidate", value };
}
// Beacon URLs are endpoint observations, retaining the upstream API support.
if (name === "sendBeacon" || name.endsWith(".sendBeacon"))
return literal(stringValue(args[0]));
return candidate(stringValue(args[0]));
if (name === "loadURL" || name.endsWith(".loadURL")) {
const value = stringValue(args[0]);
return value === undefined ? undefined : { kind: "candidate", value };
@@ -110,6 +110,7 @@ export interface JavaScriptFindingContext {
) =>
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
| undefined;
readonly xhrOpenRequest?: (node: t.Node) => boolean;
readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean;
readonly rpcScan: JavaScriptRpcScan;
/** Shared bounded argv/nullish argument shapes for optional-slot signatures. */
+84 -14
View File
@@ -585,12 +585,17 @@ describe("web bundle source-map coverage", () => {
});
describe("web bundle static-analysis parity", () => {
it("recognizes endpoints through nested dynamic receivers like static analysis", () => {
it("qualifies network spellings through nested dynamic receivers", () => {
const result = analyzeWebBundleFixture(
inspection('window[recv].fetch("/nested-fetch");'),
);
expect(result.observations.endpoints).toContainEqual(
expect.objectContaining({ value: "/nested-fetch" }),
expect(result.observations.endpoints).toEqual([]);
expect(result.inferences).toContainEqual(
expect.objectContaining({
kind: "endpoint_candidate",
value: "/nested-fetch",
confidence: "low",
}),
);
});
@@ -628,11 +633,17 @@ describe("web bundle static-analysis parity", () => {
);
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual(["/shadowed-parent-xhr"]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value)
.sort(),
).toEqual([
"/dav/",
"/dynamic-method",
"/shadowed-parent-xhr",
"/xhr-get",
"api/relative",
"https://xhr.example.test/submit",
]);
expect(result.inferences).toContainEqual(
@@ -654,9 +665,9 @@ describe("web bundle API classification parity", () => {
indexedDB.open("GET", "/storage-named-xhr");
`;
expect(
analyzeWebBundleFixture(inspection(source)).observations.endpoints.map(
({ value }) => value,
),
analyzeWebBundleFixture(inspection(source))
.inferences.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value),
).toEqual(["/storage-named-xhr"]);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.endpoints.map(({ value }) => value)).toEqual([
@@ -686,7 +697,7 @@ describe("web bundle API classification parity", () => {
);
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual(["/api", "/dav"]);
).toEqual(["/api"]);
});
it("classifies optional and one-hop aliased open calls", () => {
@@ -733,7 +744,7 @@ describe("web bundle API classification parity", () => {
);
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual(["/search", "/v1/headers"]);
).toEqual([]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
@@ -741,7 +752,9 @@ describe("web bundle API classification parity", () => {
.sort(),
).toEqual([
"../search",
"/search",
"/users",
"/v1/headers",
"cache-key",
"https://api.example.test/headers",
"https://api.example.test/users",
@@ -839,13 +852,12 @@ describe("web bundle explicit request reference forms", () => {
caches.open(name, "#cache-name");
fs.open(path, "r");
`;
const expected = [
const expected: string[] = [];
const expectedCandidates = [
"?view=1",
"#view=1",
"#literal=1",
"https:api.example.test/xhr",
].sort();
const expectedCandidates = [
...references,
...ambiguous,
"POST",
@@ -894,7 +906,7 @@ describe("web bundle endpoint candidate classification", () => {
const result = analyzeWebBundleFixture(inspection(source));
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual(["login", "ping"]);
).toEqual(["login"]);
const candidates = result.inferences.filter(
({ kind }) => kind === "endpoint_candidate",
);
@@ -905,6 +917,7 @@ describe("web bundle endpoint candidate classification", () => {
"X",
"login",
"passwordControl",
"ping",
"token",
"users",
]);
@@ -1857,7 +1870,6 @@ it("retains direct and literal computed call names", () => {
expect(result.observations.endpoints.map(({ value }) => value)).toEqual([
"/direct",
"/literal",
"/nested-receiver",
]);
expect(
result.observations.chunks.edges.map(({ specifier }) => specifier),
@@ -2113,3 +2125,61 @@ it("retains captured Next script identity while deriving decoded route patterns"
]),
);
});
it("qualifies local browser API spellings while preserving intrinsic requests", () => {
const result = analyzeWebBundleFixture(
inspection(`
const local = { fetch(x) { return x; }, sendBeacon(x) { return x; }, EventSource(x) { return x; } };
local.fetch('/local-fetch'); local.sendBeacon('/local-beacon'); local.EventSource('/local-events');
const other = { open(a,b) { return b; }, post(x) { return x; }, put(x) { return x; }, patch(x) { return x; }, request(x) { return x; } };
other.open('GET', '/local-open'); other.post('/local-post'); other.put('/local-put'); other.patch('/local-patch'); other.request('/local-request');
fetch('/intrinsic-fetch'); navigator.sendBeacon('/intrinsic-beacon'); new EventSource('/intrinsic-events');
`),
);
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual(["/intrinsic-beacon", "/intrinsic-events", "/intrinsic-fetch"]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value)
.sort(),
).toEqual([
"/local-beacon",
"/local-events",
"/local-fetch",
"/local-open",
"/local-patch",
"/local-post",
"/local-put",
"/local-request",
]);
});
it.each([
["", true],
["xhr.open = replacement;", false],
["XMLHttpRequest.prototype.open = replacement;", false],
["const alias = xhr; alias.open = replacement;", false],
["mutate(xhr);", false],
])(
"preserves XHR receiver proof across visible mutation: %s",
(mutation, observed) => {
const source = `const xhr = new XMLHttpRequest(); ${mutation} xhr.open('GET', '/xhr');`;
const result = analyzeWebBundleFixture(inspection(source));
expect(result.observations.endpoints.map(({ value }) => value)).toEqual(
observed ? ["/xhr"] : [],
);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value),
).toEqual(observed ? [] : ["/xhr"]);
expect(analyzeJavaScriptStaticSource(source).endpoints).toContainEqual(
expect.objectContaining({
value: "/xhr",
...(observed ? {} : { interpretation: "candidate" }),
}),
);
},
);
+34 -3
View File
@@ -1,3 +1,8 @@
import {
isBrowserNetworkRequest,
createXhrOpenRequestQuery,
} from "./javascript/javascriptNetworkRequests.js";
import type { JavaScriptSemanticAnalysisState } from "./javascript/javascriptSemanticState.js";
import type {
AnalysisAccumulator,
IncludedScript,
@@ -89,6 +94,7 @@ export const analyzeScript = (
}
return isIntrinsicMapLookup(node, index.bindings, index) === true;
};
const isXhrRequest = createXhrOpenRequestQuery(index);
const rpc = createJavaScriptRpcScan(
script.source.artifact.text,
receivers.rpcClientRoots,
@@ -98,7 +104,15 @@ export const analyzeScript = (
enter: (node, parent) => {
accumulator.visitedNodes += 1;
routeScan.inspect(node, parent);
inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup);
inspectNode(
script,
node,
accumulator,
openReceiverFacts,
isMapLookup,
index.bindings,
isXhrRequest,
);
const endpoint = rpc.inspect(node);
if (endpoint !== undefined)
addFinding({
@@ -134,6 +148,8 @@ const inspectNode = (
isMapLookup: (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
) => boolean,
bindings: JavaScriptSemanticAnalysisState,
isXhrRequest: (node: t.Node) => boolean,
): void => {
const moduleSource = semanticStaticModuleSource(node);
if (moduleSource !== null)
@@ -160,7 +176,15 @@ const inspectNode = (
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
)
inspectCall(script, node, accumulator, openReceiverFacts, isMapLookup);
inspectCall(
script,
node,
accumulator,
openReceiverFacts,
isMapLookup,
bindings,
isXhrRequest,
);
};
const inspectCall = (
@@ -171,6 +195,8 @@ const inspectCall = (
isMapLookup: (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
) => boolean,
bindings: JavaScriptSemanticAnalysisState,
isXhrRequest: (node: t.Node) => boolean,
): void => {
const name = calleeName(node.callee);
const first = stringValue(node.arguments[0]);
@@ -206,8 +232,13 @@ const inspectCall = (
node.callee,
openReceiverFacts.get(node.start ?? -1),
);
const mapLookup = isMapLookup(node);
const knownRequest =
isBrowserNetworkRequest(node, bindings) || isXhrRequest(node);
const networkEndpoint =
endpoint !== undefined && !isMapLookup(node) ? endpoint : undefined;
endpoint !== undefined && !mapLookup
? { ...endpoint, kind: knownRequest ? "endpoint" : endpoint.kind }
: undefined;
if (networkEndpoint?.kind === "candidate")
addEndpointCandidate(
script,
@@ -73,3 +73,72 @@ cliTest(
});
},
);
cliTest(
"qualifies local browser request spellings through CLI Evidence",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-network-spellings-");
await writeFixtureFiles(root, {
"index.js": `
const local = { fetch(x) { return x; }, sendBeacon(x) { return x; }, EventSource(x) { return x; } };
local.fetch('/local-fetch'); local.sendBeacon('/local-beacon'); local.EventSource('/local-events');
const other = { open(a,b) { return b; }, post(x) { return x; }, put(x) { return x; }, patch(x) { return x; }, request(x) { return x; } };
other.open('GET', '/local-open'); other.post('/local-post'); other.put('/local-put'); other.patch('/local-patch'); other.request('/local-request');
fetch('/intrinsic-fetch'); navigator.sendBeacon('/intrinsic-beacon'); new EventSource('/intrinsic-events');
`,
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
const endpoints = result.graph.nodes.filter(
({ kind }) => kind === "endpoint",
);
expect(endpoints).toHaveLength(11);
for (const endpoint of endpoints) {
const observation = endpoint.observations[0]!;
const local = String(observation.properties.value).startsWith("/local-");
expect(observation.evidence).toMatchObject(
local
? { state: "inferred", confidence: "low" }
: { state: "observed", confidence: "exact" },
);
expect(observation.properties.interpretation).toBe(
local ? "candidate" : undefined,
);
if (local)
expect(
result.graph.edges.find(
({ target_node_id }) => target_node_id === endpoint.node_id,
)?.evidence,
).toMatchObject({ state: "inferred", confidence: "low" });
}
},
);
cliTest(
"preserves binding-backed XHR identity and qualifies replaced open methods",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-xhr-identity-");
await writeFixtureFiles(root, {
"index.js": `
const xhr = new XMLHttpRequest();
xhr.open('GET', '/intact-xhr');
const replaced = new XMLHttpRequest();
replaced.open = (method, value) => value;
replaced.open('GET', '/replaced-xhr');
{ const XMLHttpRequest = class {}; const local = new XMLHttpRequest(); local.open('GET', '/local-xhr'); }
`,
});
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
const endpoints = result.graph.nodes.filter(
({ kind }) => kind === "endpoint",
);
expect(endpoints).toHaveLength(3);
for (const endpoint of endpoints) {
const observation = endpoint.observations[0]!;
expect(observation.evidence).toMatchObject(
observation.properties.value === "/intact-xhr"
? { state: "observed", confidence: "exact" }
: { state: "inferred", confidence: "low" },
);
}
},
);