Files
rea/src/domain/webBundleAnalyzerInspection.ts
T
morluto fbfdb6abe9 fix(javascript): distinguish Map lookups from network endpoints
Resolve unchanged Map operations through shared binding, capture, mutation,
and ambient escape facts. Reuse that proof in application and captured
bundle analysis; unresolved get/delete receivers remain inferred candidates.

Preserve repeated native operations without manufacturing receiver escapes.
Retain uncertainty for real escapes, opaque eval, and changed constructor
adder hooks. Converge stored aliases at the producing boundary.

Adapt the JavaScript changes from #4863, excluding its Linux resource fix.
Add captured-bundle parity, repeated-operation and constructor-hook coverage.

Fixes #4853
Co-authored-by: abdooo2235 <abdalrhmantheiv15@gmail.com>
2026-10-18 08:25:06 +08:00

544 lines
16 KiB
TypeScript

import * as t from "@babel/types";
import { sanitizeEndpointCandidate } from "./browserObservation.js";
import { compositeKey } from "./unicodeCodePointOrder.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js";
import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js";
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperations.js";
import {
semanticStaticPropertyKey,
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascript/javascriptAstValues.js";
import {
calleeName,
endpointArgument,
stringValue,
} from "./javascript/javascriptStaticAnalysisHelpers.js";
import {
location,
locationFields,
objectString,
objectValue,
resolveSpecifier,
isUrlLikeModuleSpecifier,
} from "./webBundleAnalyzerAst.js";
import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js";
import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js";
type BundleObservations = WebBundleAnalysis["observations"];
type ChunkEdge = BundleObservations["chunks"]["edges"][number];
type Finding = BundleObservations["routes"][number];
type WebMcpDeclaration = BundleObservations["webmcp_declarations"][number];
type Inference = WebBundleAnalysis["inferences"][number];
type BrowserScript = WebPageInspection["scripts"]["items"][number];
type IncludedSource = Extract<BrowserScript["source"], { included: true }>;
export type IncludedScript = Omit<BrowserScript, "source"> & {
readonly source: IncludedSource;
};
export interface AnalysisAccumulator {
readonly edges: ChunkEdge[];
readonly routes: Finding[];
readonly endpoints: Finding[];
readonly webMcp: WebMcpDeclaration[];
readonly inferences: Inference[];
readonly seen: Set<string>;
visitedNodes: number;
parsedScripts: number;
readonly failedScripts: string[];
readonly recoveredScripts: string[];
}
export const analyzeScript = (
script: IncludedScript,
accumulator: AnalysisAccumulator,
): void => {
const file = parseJavaScriptSource(script.source.artifact.text);
if (file === null) {
accumulator.failedScripts.push(script.script_key);
return;
}
accumulator.parsedScripts += 1;
if ((file.errors?.length ?? 0) > 0)
accumulator.recoveredScripts.push(script.script_key);
detectVendorFingerprints(script, accumulator);
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(file.program),
);
completeSemanticSteps(index.indexAmbientWritesSteps());
const { receivers } = index;
const openReceiverFacts = receivers.openReceivers;
let mutationFactsIndexed = false;
const isMapLookup = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): boolean => {
const proof = isIntrinsicMapLookup(node, index.bindings);
if (proof !== undefined) return proof;
if (!mutationFactsIndexed) {
completeSemanticSteps(index.indexMemberMutationFactsSteps());
mutationFactsIndexed = true;
}
return isIntrinsicMapLookup(node, index.bindings, index) === true;
};
const rpc = createJavaScriptRpcScan(
script.source.artifact.text,
receivers.rpcClientRoots,
index,
);
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup);
const endpoint = rpc.inspect(node);
if (endpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
rawValue: endpoint.value,
mechanism: endpoint.mechanism,
node,
accumulator,
});
},
});
for (const endpoint of rpc.finish())
if (endpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
rawValue: endpoint.value,
mechanism: endpoint.mechanism,
node: endpoint.node,
accumulator,
});
};
const inspectNode = (
script: IncludedScript,
node: t.Node,
accumulator: AnalysisAccumulator,
openReceiverFacts: ReadonlyMap<number, JavaScriptOpenReceiverFact>,
isMapLookup: (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
) => boolean,
): void => {
if (
(t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) &&
node.source !== undefined
)
addEdge({
script,
specifier: node.source.value,
kind: "static_import",
node,
accumulator,
});
else if (t.isExportNamedDeclaration(node) && node.source != null)
addEdge({
script,
specifier: node.source.value,
kind: "static_import",
node,
accumulator,
});
else if (t.isImportExpression(node)) {
const specifier = stringValue(node.source);
if (specifier !== undefined)
addEdge({
script,
specifier,
kind: "dynamic_import",
node,
accumulator,
});
}
if (t.isObjectProperty(node)) inspectRouteProperty(script, node, accumulator);
if (
t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
)
inspectCall(script, node, accumulator, openReceiverFacts, isMapLookup);
};
const inspectCall = (
script: IncludedScript,
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
accumulator: AnalysisAccumulator,
openReceiverFacts: ReadonlyMap<number, JavaScriptOpenReceiverFact>,
isMapLookup: (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
) => boolean,
): void => {
const name = calleeName(node.callee);
const first = stringValue(node.arguments[0]);
if ((name === "require" || name.endsWith(".require")) && first !== undefined)
addEdge({
script,
specifier: first,
kind: "require",
node,
accumulator,
});
if (name === "importScripts" || name.endsWith(".importScripts"))
for (const argument of node.arguments) {
const specifier = stringValue(argument);
if (specifier === undefined) continue;
addEdge({ script, specifier, kind: "worker_import", node, accumulator });
}
if (
routeCallNames.some(
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
)
) {
if (first !== undefined)
addFinding({
collection: accumulator.routes,
script,
rawValue: first,
mechanism: `call:${name}`,
node,
accumulator,
});
addRouteFrameworkInference({
script,
name,
node,
accumulator,
});
}
const endpoint = endpointArgument(
name,
node.arguments,
node.callee,
openReceiverFacts.get(node.start ?? -1),
);
const networkEndpoint =
endpoint !== undefined && !isMapLookup(node) ? endpoint : undefined;
if (networkEndpoint?.kind === "candidate")
addEndpointCandidate(
script,
node,
name,
networkEndpoint.value,
accumulator,
);
else if (networkEndpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
rawValue: networkEndpoint.value,
mechanism: `call:${name}`,
node,
accumulator,
});
if (isWebMcpRegistrationCallee(node.callee))
addWebMcpDeclaration(script, node, accumulator);
};
/** Match exact WebMCP receiver and method properties without flattening keys. */
const isWebMcpRegistrationCallee = (callee: t.Node): boolean => {
const method = unwrapJavaScriptExpression(callee).node;
if (!t.isMemberExpression(method) && !t.isOptionalMemberExpression(method))
return false;
if (
semanticStaticPropertyKey(method.property, method.computed) !==
"registerTool"
)
return false;
const receiver = unwrapJavaScriptExpression(method.object).node;
if (t.isIdentifier(receiver, { name: "modelContext" })) return true;
return (
(t.isMemberExpression(receiver) ||
t.isOptionalMemberExpression(receiver)) &&
semanticStaticPropertyKey(receiver.property, receiver.computed) ===
"modelContext"
);
};
const inspectRouteProperty = (
script: IncludedScript,
node: t.ObjectProperty,
accumulator: AnalysisAccumulator,
): void => {
const key = semanticStaticPropertyName(node.key, node.computed);
const route = stringValue(node.value);
if ((key === "path" || key === "route") && route !== undefined)
addFinding({
collection: accumulator.routes,
script,
rawValue: route,
mechanism: `property:${key}`,
node,
accumulator,
});
};
interface AddEdgeContext {
readonly script: IncludedScript;
readonly specifier: string;
readonly kind: ChunkEdge["kind"];
readonly node: t.Node;
readonly accumulator: AnalysisAccumulator;
}
const addEdge = (context: AddEdgeContext): void => {
const key = `edge\0${context.script.script_key}\0${context.kind}\0${context.specifier}`;
addUnique(context.accumulator, key, () =>
context.accumulator.edges.push({
from_script_key: context.script.script_key,
kind: context.kind,
specifier: context.specifier,
resolved_url:
context.kind === "worker_import" ||
isUrlLikeModuleSpecifier(context.specifier)
? resolveSpecifier(context.specifier, context.script.url)
: null,
location: location(context.script.script_key, context.node),
}),
);
};
interface AddFindingContext {
readonly collection: Finding[];
readonly script: IncludedScript;
readonly rawValue: string;
readonly mechanism: string;
readonly node: t.Node;
readonly accumulator: AnalysisAccumulator;
}
const addEndpointCandidate = (
script: IncludedScript,
node: t.Node,
name: string,
rawValue: string,
accumulator: AnalysisAccumulator,
): void => {
const value = sanitizeEndpointCandidate(rawValue);
const detector = `call:${name}:unverified-network-receiver`;
const key = compositeKey([
"endpoint-candidate",
script.script_key,
value,
detector,
]);
// This is a per-script candidate inventory, not a callsite list. Keep the
// first representative source position without multiplying inline records.
addUnique(accumulator, key, () =>
accumulator.inferences.push({
kind: "endpoint_candidate",
value,
confidence: "low",
basis: [{ ...basis(script, detector), ...locationFields(node) }],
}),
);
};
const addFinding = (context: AddFindingContext): void => {
const value = sanitizeEndpointCandidate(context.rawValue);
const key = `finding\0${context.mechanism}\0${context.script.script_key}\0${value}`;
addUnique(context.accumulator, key, () =>
context.collection.push({
value,
mechanism: context.mechanism,
location: location(context.script.script_key, context.node),
}),
);
};
const addWebMcpDeclaration = (
script: IncludedScript,
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
accumulator: AnalysisAccumulator,
): void => {
const declaration = node.arguments[0];
if (!t.isObjectExpression(declaration)) return;
const name = objectString(declaration, "name");
const description = objectString(declaration, "description");
const schema =
objectValue(declaration, "inputSchema") ??
objectValue(declaration, "input_schema");
const properties = t.isObjectExpression(schema)
? objectValue(schema, "properties")
: undefined;
const schemaPropertyNames = t.isObjectExpression(properties)
? properties.properties.flatMap((property) => {
if (!t.isObjectProperty(property)) return [];
const key = semanticStaticPropertyKey(property.key, property.computed);
return key === null ? [] : [key];
})
: [];
const metadata = {
name: name ?? null,
description: description ?? null,
schema_property_names: [...new Set(schemaPropertyNames)].sort(),
};
// Deduplicate the metadata we return, preserving omission and descriptions.
// Property names are a variable-length source-derived list: NUL-joins
// would collapse ["a\0b"] with ["a", "b"] and drop a declaration.
const key = compositeKey(["webmcp", script.script_key, metadata]);
addUnique(accumulator, key, () =>
accumulator.webMcp.push({
...metadata,
trust: "page-declared-untrusted",
location: location(script.script_key, node),
}),
);
};
const detectVendorFingerprints = (
script: IncludedScript,
accumulator: AnalysisAccumulator,
): void => {
const text = script.source.artifact.text;
for (const detector of vendorDetectors) {
if (!detector.patterns.some((pattern) => text.includes(pattern))) continue;
const key = `vendor\0${script.script_key}\0${detector.value}`;
addUnique(accumulator, key, () =>
accumulator.inferences.push({
kind: detector.kind,
value: detector.value,
confidence: detector.confidence,
basis: [basis(script, detector.detector)],
}),
);
}
};
interface RouteFrameworkContext {
readonly script: IncludedScript;
readonly name: string;
readonly node: t.Node;
readonly accumulator: AnalysisAccumulator;
}
const addRouteFrameworkInference = (context: RouteFrameworkContext): void => {
const value =
context.name.endsWith("useRoutes") ||
context.name.endsWith("createBrowserRouter")
? "React Router-compatible route API"
: "Generic route registration API";
const key = `route-framework\0${context.script.script_key}\0${value}`;
addUnique(context.accumulator, key, () =>
context.accumulator.inferences.push({
kind: "route_framework",
value,
confidence: "medium",
basis: [
{
...basis(context.script, `call:${context.name}`),
...locationFields(context.node),
},
],
}),
);
};
const addUnique = (
accumulator: AnalysisAccumulator,
key: string,
add: () => void,
): void => {
if (accumulator.seen.has(key)) return;
accumulator.seen.add(key);
add();
};
const basis = (script: IncludedScript, detector: string) => ({
script_key: script.script_key,
artifact_sha256: script.source.artifact.sha256,
line: null,
column: null,
detector,
});
export const emptyAccumulator = (): AnalysisAccumulator => ({
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
});
export const isIncludedScript = (
script: BrowserScript,
): script is IncludedScript => script.source.included;
const routeCallNames = [
"route",
"addRoute",
"useRoutes",
"createBrowserRouter",
"createHashRouter",
] as const;
const vendorDetectors: readonly {
readonly value: string;
readonly detector: string;
readonly patterns: readonly string[];
readonly confidence: Inference["confidence"];
readonly kind: Inference["kind"];
}[] = [
{
value: "webpack",
detector: "webpack-runtime",
patterns: ["__webpack_require__"],
confidence: "high",
kind: "bundle_runtime",
},
{
value: "Vite",
detector: "vite-runtime",
patterns: ["__vite__", "import.meta.hot"],
confidence: "medium",
kind: "bundle_runtime",
},
{
value: "React",
detector: "react-runtime",
patterns: ["__REACT_DEVTOOLS_GLOBAL_HOOK__", "React.createElement"],
confidence: "medium",
kind: "vendor_fingerprint",
},
{
value: "Vue",
detector: "vue-runtime",
patterns: ["__VUE__", "createApp("],
confidence: "medium",
kind: "vendor_fingerprint",
},
{
value: "Next.js",
detector: "next-runtime",
patterns: ["__NEXT_DATA__", "/_next/"],
confidence: "high",
kind: "vendor_fingerprint",
},
{
value: "Angular",
detector: "angular-runtime",
patterns: ["ɵɵdefineComponent"],
confidence: "high",
kind: "vendor_fingerprint",
},
{
value: "Svelte",
detector: "svelte-runtime",
patterns: ["svelte/internal"],
confidence: "medium",
kind: "vendor_fingerprint",
},
];