Files
reverse-engineering-anything/src/domain/javascript/javascriptStaticAnalysisCalls.ts
T
Minghang Chamling 5fc4d792ca fix(javascript): handle oversized inline source maps (#1386)
* fix(javascript): handle oversized inline source maps

* fix(javascript): avoid source map directive regex overflow
2026-10-10 10:24:38 +08:00

340 lines
11 KiB
TypeScript

import * as t from "@babel/types";
import { sanitizeEndpointCandidate } from "../browserObservation.js";
import {
addFindingOnce,
addLocatedFinding,
} from "./javascriptStaticAnalysisFindings.js";
import {
STATIC_ROUTE_CALL_NAMES,
argumentValue,
calleeName,
endpointArgument as endpointArgumentHelper,
prefixedArgument,
range,
rangeForOffsets,
sourceSlice,
staticPath,
staticPathResolutionContext,
storageKind,
stringValue,
} from "./javascriptStaticAnalysisHelpers.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
import type {
JavaScriptAnalysisAccumulator as AnalysisAccumulator,
JavaScriptEndpointInput as EndpointInput,
JavaScriptFindingContext as FindingContext,
JavaScriptReferenceInput as ReferenceInput,
} from "./javascriptStaticAnalysisState.js";
type JavaScriptCallLike =
| t.CallExpression
| t.OptionalCallExpression
| t.NewExpression;
/** Inspect one call or new expression for references, endpoints, storage, and roles. */
export const inspectCall = (
source: string,
node: JavaScriptCallLike,
context: FindingContext,
): void => {
const { accumulator } = context;
const name = calleeName(node.callee);
const first = stringValue(node.arguments[0]);
const module = context.accumulator.moduleRangeIndex.find(node.start);
const moduleRequireName = module?.requireName ?? null;
const requireCall = requireCallSpecifier(node, name, moduleRequireName);
if (requireCall !== null)
addReference(context, {
node,
kind: "require",
specifier: requireCall.specifier,
});
const chunkLoad = chunkLoadSpecifier(node, name, moduleRequireName);
if (chunkLoad !== null)
addReference(context, {
node,
kind: "dynamic-import",
specifier: chunkLoad.specifier,
});
for (const specifier of vitePreloadDependencySpecifiers(source, node, name))
addReference(context, { node, kind: "dynamic-import", specifier });
if (name === "Worker" || name.endsWith(".Worker"))
addReference(context, { node, kind: "worker", specifier: first });
if (name.endsWith("serviceWorker.register"))
addReference(context, { node, kind: "service-worker", specifier: first });
inspectEndpointCall(node, name, first, context);
inspectStorageCall(node, name, first, context);
if (name.endsWith("loadFile") && first !== undefined)
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
node,
value: {
role: "renderer",
path: first,
resolution_context: "filesystem-expression",
mechanism: `call:${name}`,
module_key: null,
location: range(node),
},
});
if (name.endsWith("loadURL") && first !== undefined)
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
node,
value: {
role: "renderer",
path: first,
resolution_context: "module-specifier",
mechanism: `call:${name}`,
module_key: null,
location: range(node),
},
});
};
const requireCallSpecifier = (
node: JavaScriptCallLike,
name: string,
moduleRequireName: string | null,
): { readonly specifier: string | undefined } | null => {
if (name === "require" || name.endsWith(".require"))
return { specifier: argumentValue(node.arguments[0]) };
if (name === "__webpack_require__" || name === moduleRequireName)
return { specifier: argumentValue(node.arguments[0]) };
if (
name === "__webpack_require__.bind" ||
(moduleRequireName !== null && name === `${moduleRequireName}.bind`)
)
return { specifier: argumentValue(node.arguments[1]) };
return null;
};
const chunkLoadSpecifier = (
node: JavaScriptCallLike,
name: string,
moduleRequireName: string | null,
): { readonly specifier: string | undefined } | null => {
if (
name !== "__webpack_require__.e" &&
(moduleRequireName === null || name !== `${moduleRequireName}.e`)
)
return null;
return { specifier: prefixedArgument("chunk:", node.arguments[0]) };
};
const vitePreloadDependencySpecifiers = (
source: string,
node: JavaScriptCallLike,
name: string,
): readonly string[] => {
if (name !== "__vite__mapDeps" || !t.isArrayExpression(node.arguments[0]))
return [];
const indexes = node.arguments[0].elements.flatMap((element) =>
t.isNumericLiteral(element) ? [element.value] : [],
);
if (indexes.length !== node.arguments[0].elements.length) return [];
const table = vitePreloadDependencyTable(source);
return indexes.flatMap((index) => table[index] ?? []);
};
const vitePreloadDependencyTable = (source: string): readonly string[] => {
const match =
/\.\s*f\s*\|\|\s*\(\s*[^)]*?\.f\s*=\s*\[([\s\S]*?)\]\s*\)/u.exec(source);
const body = match?.[1];
if (body === undefined) return [];
return [
...body.matchAll(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/gu),
].map((literal) => literal[1] ?? literal[2] ?? "");
};
const inspectEndpointCall = (
node: JavaScriptCallLike,
name: string,
first: string | undefined,
context: FindingContext,
): void => {
const route = STATIC_ROUTE_CALL_NAMES.some(
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
);
if (route && first !== undefined)
addEndpoint(context, {
node,
kind: "route",
value: first,
mechanism: `call:${name}`,
});
const receiverFact = context.openReceiverFacts?.get(node.start ?? -1);
const endpoint = endpointArgumentHelper(
name,
node.arguments,
node.callee,
receiverFact,
);
if (endpoint !== undefined)
addEndpoint(context, {
node,
kind: "network",
value: endpoint,
mechanism: `call:${name}`,
});
};
export const inspectRouteProperty = (
node: t.ObjectProperty,
context: FindingContext,
): void => {
const key = semanticStaticPropertyName(node.key, node.computed);
if ((key === "path" || key === "route") && t.isStringLiteral(node.value))
addEndpoint(context, {
node,
kind: "route",
value: node.value.value,
mechanism: `property:${key}`,
});
};
export const inspectRoleProperty = (
node: t.ObjectProperty,
context: FindingContext,
): void => {
if (semanticStaticPropertyName(node.key, node.computed) !== "preload") return;
const path = staticPath(node.value);
if (path === undefined) return;
addLocatedFinding(context, {
collection: context.accumulator.roles,
key: `role\0preload\0${path}`,
node,
value: {
role: "preload",
path,
resolution_context: staticPathResolutionContext(node.value),
mechanism: "property:preload",
module_key: null,
location: range(node),
},
});
};
const inspectStorageCall = (
node: JavaScriptCallLike,
name: string,
first: string | undefined,
context: FindingContext,
): void => {
const receiverFact = context.openReceiverFacts?.get(node.start ?? -1);
const storage =
receiverFact === "local" ||
receiverFact === "local-indexed-db" ||
receiverFact === "local-cache-storage"
? undefined
: receiverFact === "indexed-db"
? "indexed-db"
: receiverFact === "cache-storage"
? "cache-storage"
: storageKind(name);
if (storage === undefined) return;
addLocatedFinding(context, {
collection: context.accumulator.storage,
key: `storage\0${storage}\0${first ?? ""}`,
node,
value: {
kind: storage,
name: first ?? null,
mechanism: `call:${name}`,
module_key: null,
location: range(node),
},
});
};
export const addReference = (
context: FindingContext,
input: ReferenceInput,
): void => {
const specifier = input.specifier ?? null;
const reference =
specifier === null
? {
specifier: null,
expression: sourceSlice(context.source, input.node),
}
: { specifier, expression: null };
addLocatedFinding(context, {
collection: context.accumulator.references,
key: `reference\0${input.kind}\0${reference.specifier ?? reference.expression}`,
node: input.node,
value: {
kind: input.kind,
...reference,
module_key: null,
location: range(input.node),
},
});
};
const addEndpoint = (context: FindingContext, input: EndpointInput): void =>
addLocatedFinding(context, {
collection: context.accumulator.endpoints,
key: `endpoint\0${input.kind}\0${input.value}`,
node: input.node,
value: {
kind: input.kind,
value: sanitizeEndpointCandidate(input.value),
mechanism: input.mechanism,
module_key: null,
location: range(input.node),
},
});
export const addSourceMapDirectives = (
source: string,
comments: readonly t.Comment[],
accumulator: AnalysisAccumulator,
): void => {
// Read real comment tokens only; quoted text can contain identical markers.
// Keep both current/legacy line annotations and minifier block annotations.
for (const comment of comments) {
const start = comment.start;
const end = comment.end;
if (
start === undefined ||
start === null ||
end === undefined ||
end === null
)
continue;
const commentSource = source.slice(start, end);
if (
!(commentSource.startsWith("//") || commentSource.startsWith("/*")) ||
!(commentSource[2] === "#" || commentSource[2] === "@")
)
continue;
// Single-class searches avoid regex backtracking over unbounded whitespace
// or inline URLs, which can exhaust the engine stack on two-byte strings.
const nameOffset = commentSource.slice(3).search(/\S/u);
if (nameOffset === -1) continue;
const nameStart = 3 + nameOffset;
if (!commentSource.startsWith("sourceMappingURL", nameStart)) continue;
const nameEnd = nameStart + "sourceMappingURL".length;
const equalsOffset = commentSource.slice(nameEnd).search(/\S/u);
if (equalsOffset === -1) continue;
const equalsStart = nameEnd + equalsOffset;
if (!commentSource.startsWith("=", equalsStart)) continue;
const urlOffset = commentSource.slice(equalsStart + 1).search(/\S/u);
if (urlOffset === -1) continue;
const declaredStart = equalsStart + 1 + urlOffset;
const terminator = commentSource.slice(declaredStart).search(/[\s*]/u);
const declaredEnd =
terminator === -1 ? commentSource.length : declaredStart + terminator;
if (declaredEnd === declaredStart) continue;
const declared = commentSource.slice(declaredStart, declaredEnd);
const location = rangeForOffsets(source, start, start + declaredEnd);
addFindingOnce(accumulator, `source-map\0${declared}`, () =>
accumulator.sourceMaps.push({ declared_url: declared, location }),
);
}
};