fix(javascript): align module lookup and source evidence

Preserve CommonJS literal paths and confined ESM URL semantics, including package main fallback, directory intent, Unicode identity, and present opaque modules. Parse extraction inputs at their AST/source owner, retain skipped HTML and source-map coverage, and distinguish endpoint candidates from proven receiver facts. Version structural fingerprints with computed literal contents and propagate them through worker contracts.

Reviewed fixes include #1810, #1840, #2642, #2700, #2702, #2714, #2803, #2960, #2968, #2986, #3035, and #3036.

Validation: 158 resolver tests passed; public CLI/MCP module lookup controls, package-main/Unicode workflows, and six builtin workflows passed in the current compiled lane. Worker schema/fingerprint regressions passed. Large public pdf.js completion and real Electron analysis remain unverified.
This commit is contained in:
morluto committed 2026-10-14 08:46:50 +08:00
1 parent 04672f2b91
commit b58adafa4f
63 files changed
+4066 -326

No files matched your search

@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { htmlArtifactReferences } from "./htmlArtifactReferences.js";
import {
HTML_INLINE_SCRIPT_LOCATION_LIMIT,
htmlArtifactReferences,
} from "./htmlArtifactReferences.js";
it.each([
['<script data-src="decoy.js"></script>', []],
@@ -35,17 +38,21 @@ it.each([
).toEqual(expected);
});
it("retains exact opening-tag offsets rather than the script body", () => {
it("retains exact opening-tag coordinates rather than the script body", () => {
const opening = '<script\n title=">" src=actual.js>';
const prefix = "<!doctype html>\n\n";
const text = `${prefix}${opening}ignored body</script>`;
expect(htmlArtifactReferences(text)).toEqual({
baseHref: null,
inlineScripts: [],
inlineScriptCount: 0,
scripts: [
{
scriptPath: "actual.js",
startOffset: prefix.length,
endOffset: prefix.length + opening.length,
location: {
start: { line: 3, column: 0 },
end: { line: 4, column: ' title=">" src=actual.js>'.length },
},
},
],
});
@@ -68,3 +75,71 @@ it("traverses deep valid document content without recursive stack growth", () =>
htmlArtifactReferences(text).scripts.map(({ scriptPath }) => scriptPath),
).toEqual(["actual.js"]);
});
it.each([
["<script>run();</script>", ["classic"]],
['<script type="">run();</script>', ["classic"]],
['<script type=" Text/JavaScript ">run();</script>', ["classic"]],
['<script type="application/x-javascript">run();</script>', ["classic"]],
['<script language="javascript">run();</script>', ["classic"]],
['<script language="">run();</script>', ["classic"]],
['<script type="MODULE">import "./x.js";</script>', ["module"]],
// Only the JavaScript MIME check strips whitespace; `module` must match.
['<script type=" module ">import "./x.js";</script>', []],
['<script language=" javascript ">run();</script>', []],
["<script nomodule>run();</script>", ["classic"]],
// A src attribute, even an empty one, means the browser ignores the body.
['<script src="actual.js">run();</script>', []],
['<script src="">run();</script>', []],
["<script> \n\t </script>", []],
['<script type="application/json">{"a":1}</script>', []],
['<script type="application/ld+json">{}</script>', []],
['<script type="importmap">{"imports":{}}</script>', []],
['<script type="speculationrules">{}</script>', []],
['<script type="text/template"><p>x</p></script>', []],
['<script language="vbscript">run()</script>', []],
["<!-- <script>run();</script> -->", []],
["<template><script>run();</script></template>", []],
["<svg><script>run();</script></svg>", []],
])("classifies inline JavaScript in %s", (text, expected) => {
expect(
htmlArtifactReferences(text).inlineScripts.map(
({ scriptType }) => scriptType,
),
).toEqual(expected);
});
it("retains exact inline script body coordinates, including unclosed scripts", () => {
const prefix = "<!doctype html>\n<script>";
const body = "\nfetch('/api');\n";
const closed = `${prefix}${body}</script><p>after</p>`;
expect(htmlArtifactReferences(closed).inlineScripts).toEqual([
{
scriptType: "classic",
location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } },
},
]);
const unclosed = `${prefix}${body}`;
expect(htmlArtifactReferences(unclosed).inlineScripts).toEqual([
{
scriptType: "classic",
location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } },
},
]);
});
it("counts every inline body but retains bounded locations", () => {
const count = 150_000;
const text = "<script>x</script>".repeat(count);
const { inlineScripts, inlineScriptCount } = htmlArtifactReferences(text);
expect(inlineScriptCount).toBe(count);
expect(inlineScripts).toHaveLength(HTML_INLINE_SCRIPT_LOCATION_LIMIT);
const column = 18 * (HTML_INLINE_SCRIPT_LOCATION_LIMIT - 1) + 8;
expect(inlineScripts.at(-1)).toEqual({
scriptType: "classic",
location: {
start: { line: 1, column },
end: { line: 1, column: column + 1 },
},
});
});
+120 -6
View File
@@ -4,23 +4,99 @@ import {
type DefaultTreeAdapterTypes,
} from "parse5";
import type {
JavaScriptSourcePoint,
JavaScriptSourceRange,
} from "./javascriptStaticAnalysisTypes.js";
interface HtmlScriptReference {
readonly scriptPath: string;
readonly startOffset: number;
readonly endOffset: number;
/** The opening tag's range in parse5 coordinates. */
readonly location: JavaScriptSourceRange;
}
/** Extract inert HTML script references and the first document base href. */
/** JavaScript written directly in a script element instead of loaded by URL. */
export interface HtmlInlineScript {
readonly scriptType: "classic" | "module";
/** The body's range, between the opening and closing tags. */
readonly location: JavaScriptSourceRange;
}
/**
* Inline bodies whose exact locations are retained per HTML file. Every body
* is still counted; locations beyond this bound are reported as omitted.
*/
export const HTML_INLINE_SCRIPT_LOCATION_LIMIT = 100;
// The JavaScript MIME type essences from the WHATWG MIME Sniffing standard.
const JAVASCRIPT_MIME_TYPES = new Set([
"application/ecmascript",
"application/javascript",
"application/x-ecmascript",
"application/x-javascript",
"text/ecmascript",
"text/javascript",
"text/javascript1.0",
"text/javascript1.1",
"text/javascript1.2",
"text/javascript1.3",
"text/javascript1.4",
"text/javascript1.5",
"text/jscript",
"text/livescript",
"text/x-ecmascript",
"text/x-javascript",
]);
/**
* Classify a script element's type as HTML "prepare the script element" does.
* Only a type attribute is whitespace-stripped, and only for the JavaScript
* MIME check; `module` must match the type string exactly, ignoring ASCII
* case. Import maps, speculation rules and data blocks are not JavaScript.
*/
const inlineScriptType = (
attrs: readonly { readonly name: string; readonly value: string }[],
): HtmlInlineScript["scriptType"] | null => {
const type = attrs.find(({ name }) => name === "type");
const language = attrs.find(({ name }) => name === "language");
const typeString =
type !== undefined
? type.value
: language !== undefined && language.value !== ""
? `text/${language.value}`
: "";
if (typeString === "") return "classic";
const mimeCandidate =
type === undefined
? typeString
: typeString.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/gu, "");
if (JAVASCRIPT_MIME_TYPES.has(asciiLowercase(mimeCandidate)))
return "classic";
return asciiLowercase(typeString) === "module" ? "module" : null;
};
const asciiLowercase = (value: string): string =>
value.replace(/[A-Z]/gu, (letter) => letter.toLowerCase());
/**
* Extract inert HTML script references, inline JavaScript bodies, and the
* first document base href.
*/
export const htmlArtifactReferences = (
text: string,
): {
readonly baseHref: string | null;
readonly scripts: readonly HtmlScriptReference[];
/** The first inline bodies in document order, up to the location limit. */
readonly inlineScripts: readonly HtmlInlineScript[];
readonly inlineScriptCount: number;
} => {
const pending: DefaultTreeAdapterTypes.Node[] = [
parse(text, { sourceCodeLocationInfo: true }),
];
const scripts: HtmlScriptReference[] = [];
const inlineScripts: HtmlInlineScript[] = [];
let inlineScriptCount = 0;
let baseHref: string | null = null;
while (pending.length > 0) {
const node = pending.pop();
@@ -39,10 +115,20 @@ export const htmlArtifactReferences = (
if (src !== undefined && src.value !== "" && location !== undefined) {
scripts.push({
scriptPath: src.value,
startOffset: location.startOffset,
endOffset: location.endOffset,
location: {
start: sourcePoint(location.startLine, location.startCol),
end: sourcePoint(location.endLine, location.endCol),
},
});
}
const inline = src === undefined ? inlineScriptBody(node) : undefined;
const scriptType =
inline === undefined ? null : inlineScriptType(node.attrs);
if (inline !== undefined && scriptType !== null) {
inlineScriptCount += 1;
if (inlineScripts.length < HTML_INLINE_SCRIPT_LOCATION_LIMIT)
inlineScripts.push({ scriptType, location: inline });
}
}
}
// Template content belongs to a separate inert document fragment. Only
@@ -51,5 +137,33 @@ export const htmlArtifactReferences = (
for (const child of node.childNodes.toReversed()) pending.push(child);
}
}
return { baseHref, scripts };
return { baseHref, scripts, inlineScripts, inlineScriptCount };
};
/** Locate a script element's non-blank body; browsers ignore it when src is set. */
const inlineScriptBody = (
node: DefaultTreeAdapterTypes.Element,
): JavaScriptSourceRange | undefined => {
const startTag = node.sourceCodeLocation?.startTag;
if (startTag === undefined) return undefined;
let blank = true;
let end: DefaultTreeAdapterTypes.TextNode["sourceCodeLocation"];
for (const child of node.childNodes) {
if (!defaultTreeAdapter.isTextNode(child)) continue;
if (blank && /\S/u.test(child.value)) blank = false;
end = child.sourceCodeLocation ?? end;
}
// An unclosed script's element range ends at its opening tag, so the body's
// own text location bounds it.
if (blank || end == null) return undefined;
return {
start: sourcePoint(startTag.endLine, startTag.endCol),
end: sourcePoint(end.endLine, end.endCol),
};
};
/** parse5 lines and UTF-16 columns are one-based; ranges use zero-based columns. */
const sourcePoint = (line: number, column: number): JavaScriptSourcePoint => ({
line,
column: column - 1,
});
@@ -6,7 +6,7 @@ const boundedTextSchema = z.string().min(1);
const boundedKeySchema = z.string().min(1);
const unsafePathSegments = new Set(["", ".", ".."]);
const isSafeRelativePath = (path: string): boolean => {
if (path.startsWith("/") || path.includes("\\")) return false;
if (path.startsWith("/")) return false;
return path.split("/").every((part) => !unsafePathSegments.has(part));
};
const relativePathSchema = z
@@ -1,5 +1,9 @@
import type { JavaScriptSourceSelection } from "./javascriptSourceSelection.js";
/** Whether an inventoried path has a source extension read by the analyzer. */
export const isJavaScriptSourcePath = (path: string): boolean =>
/\.(?:cjs|cts|mjs|mts|js|jsx|ts|tsx)$/iu.test(path);
/** Relevant file categories projected from the complete artifact inventory. */
export type JavaScriptArtifactFileKind =
| "package-json"
@@ -29,6 +33,26 @@ export interface JavaScriptArtifactFile {
};
}
/**
* One inventoried regular file outside the JavaScript-relevant kinds. Its presence
* was inventoried, including unavailable or unhashed members. The source projection
* does not read its bytes; references remain present-but-unanalyzed rather than absent.
*/
export interface JavaScriptUnanalyzedArtifactFile {
readonly path: string;
readonly container_sha256: string;
readonly sha256: string | null;
readonly bytes: number | null;
readonly inventory_artifact_id: string | null;
readonly limitations?: readonly string[];
}
/** Borrowed inventory lookup; metadata is materialized only for requested paths. */
export interface JavaScriptUnanalyzedArtifactLookup {
get(path: string): JavaScriptUnanalyzedArtifactFile | undefined;
hasDirectory(path: string, containerSha256: string | undefined): boolean;
}
/** One filesystem-backed ASAR nested beneath a directory input. */
export interface JavaScriptArtifactContainer {
readonly path: string;
@@ -40,6 +64,8 @@ export interface JavaScriptArtifactContainer {
/** Deterministic relevant-file projection plus explicit omissions. */
export interface JavaScriptArtifactFileSet {
readonly files: readonly JavaScriptArtifactFile[];
/** Borrowed inventory lookup for regular files the source projection does not read. */
readonly unanalyzed_files?: JavaScriptUnanalyzedArtifactLookup;
readonly containers: readonly JavaScriptArtifactContainer[];
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
@@ -2,6 +2,7 @@ import { parse } from "@babel/parser";
import { describe, expect, it } from "vitest";
import { fingerprintJavaScriptAst } from "./javascriptAstFingerprint.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("JavaScript AST fingerprints", () => {
it("includes complete literal values in the fingerprint", () => {
@@ -14,3 +15,62 @@ describe("JavaScript AST fingerprints", () => {
);
});
});
it.each(["obj[key]", "obj?.[key]"])(
"ignores a local computed key binding in %s",
(expression) => {
const left = parse(`function read(obj, key) { return ${expression}; }`);
const renamed = expression
.replace("obj", "value")
.replace("key", "selector");
const right = parse(
`function read(value, selector) { return ${renamed}; }`,
);
expect(fingerprintJavaScriptAst(left)).toBe(
fingerprintJavaScriptAst(right),
);
},
);
it("distinguishes computed reads from fixed named members", () => {
expect(fingerprintJavaScriptAst(parse("obj[key]"))).not.toBe(
fingerprintJavaScriptAst(parse("obj.key")),
);
expect(fingerprintJavaScriptAst(parse("obj.key"))).not.toBe(
fingerprintJavaScriptAst(parse("obj.other")),
);
expect(fingerprintJavaScriptAst(parse('obj["key"]'))).not.toBe(
fingerprintJavaScriptAst(parse('obj["other"]')),
);
});
it.each([
["obj[`left`]", "obj[`right`]"],
["obj[``]", "obj[`right`]"],
["obj[true]", "obj[false]"],
["obj[null]", "obj[false]"],
['obj["" ]', 'obj["right"]'],
["obj[`prefix-${key}`]", "obj[`changed-${key}`]"],
])("retains computed literal contents in %s", (left, right) => {
expect(fingerprintJavaScriptAst(parse(left))).not.toBe(
fingerprintJavaScriptAst(parse(right)),
);
});
it("keeps renamed computed-key bundle modules structurally matchable", () => {
const source = (name: string) => `globalThis.webpackChunkApp.push([[1], {
17(module, exports) { const ${name} = selectKey(); exports.read = value[${name}]; }
}]);`;
const before = analyzeJavaScriptStaticSource(source("key"))
.bundler_registrations[0]?.modules[0];
const after = analyzeJavaScriptStaticSource(source("selector"))
.bundler_registrations[0]?.modules[0];
expect(before).toBeDefined();
expect(after).toBeDefined();
expect(before?.structural_fingerprint_algorithm).toBe("babel-ast-v2");
expect(before?.source_sha256).not.toBe(after?.source_sha256);
expect(before?.structural_fingerprint_sha256).toMatch(/^[a-f0-9]{64}$/u);
expect(before?.structural_fingerprint_sha256).toBe(
after?.structural_fingerprint_sha256,
);
});
@@ -84,13 +84,19 @@ const semanticTokens = (node: t.Node): string[] => {
tokens.push(`boolean:${String(node.value)}`);
else if (t.isRegExpLiteral(node))
tokens.push(`regexp:${node.pattern}/${node.flags}`);
else if (t.isTemplateElement(node))
tokens.push(`template:${JSON.stringify(node.value)}`);
else if (t.isBinaryExpression(node) || t.isLogicalExpression(node))
tokens.push(`operator:${node.operator}`);
else if (t.isUnaryExpression(node) || t.isUpdateExpression(node))
tokens.push(`operator:${node.operator}`);
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
const property = propertyName(node.property);
if (property !== "") tokens.push(`property:${property}`);
// A computed identifier names a local binding, not a fixed member.
tokens.push(`computed:${String(node.computed)}`);
if (!node.computed) {
const property = propertyName(node.property);
if (property !== "") tokens.push(`property:${property}`);
}
}
if ((t.isObjectProperty(node) || t.isObjectMethod(node)) && !node.computed) {
const key = propertyName(node.key);
@@ -1,10 +1,31 @@
import { expect, it } from "vitest";
import {
compareJavaScriptExportShapesInputSchema,
javaScriptExportShapeComparisonChangeSchema,
projectedExportReturnShapesSchema,
} from "./javascriptExportShapeComparisonSchemas.js";
it("accepts exact empty export names in selectors and projections", () => {
expect(
compareJavaScriptExportShapesInputSchema.shape.left_export_name.safeParse(
"",
).success,
).toBe(true);
expect(
projectedExportReturnShapesSchema.safeParse({
...projectionWithField({
path: "/kind",
presence: "present",
state: "literal",
value: "ok",
reason: null,
}),
exported_name: "",
}).success,
).toBe(true);
});
const projectionWithField = (field: unknown) => ({
semantic_role: "export-return-shapes",
module_path: "index.js",
@@ -7,6 +7,7 @@ const evidenceIdSchema = prefixedDigestSchema("ev");
const graphIdSchema = prefixedDigestSchema("jag");
const nodeIdSchema = prefixedDigestSchema("jag_node");
const textSchema = z.string().min(1);
const exportNameSchema = z.string();
const semanticPrimitiveSchema = z.union([
z.string(),
z.number().finite(),
@@ -85,9 +86,9 @@ export const compareJavaScriptExportShapesInputSchema = z
left: evidenceSchema,
right: evidenceSchema,
left_module_path: textSchema,
left_export_name: textSchema,
left_export_name: exportNameSchema,
right_module_path: textSchema,
right_export_name: textSchema,
right_export_name: exportNameSchema,
})
.superRefine(validateJavaScriptExportShapeIdentities);
@@ -170,7 +171,7 @@ export const projectedExportReturnShapesSchema = z
.strictObject({
semantic_role: z.literal("export-return-shapes"),
module_path: textSchema,
exported_name: textSchema,
exported_name: exportNameSchema,
callable_id: textSchema,
callable_kind: z.enum(["function", "class", "method"]),
static_return_shapes: z.array(projectedReturnShapeSchema),
@@ -198,7 +199,7 @@ export const projectedExportReturnShapesSchema = z
const exportCandidateSchema = z.strictObject({
node_id: nodeIdSchema,
module_path: textSchema,
export_name: textSchema,
export_name: exportNameSchema,
matches_requested_module: z.boolean(),
matches_requested_export: z.boolean(),
});
@@ -207,7 +208,7 @@ const selectorResultShape = {
evidence_id: evidenceIdSchema,
graph_id: graphIdSchema,
requested_module_path: textSchema,
requested_export_name: textSchema,
requested_export_name: exportNameSchema,
candidates: z.array(exportCandidateSchema),
omitted_candidates: z.number().int().min(0),
};
@@ -98,7 +98,7 @@ export const buildJavaScriptExportShapeChanges = (
input.rightSelection.selection.status !== "selected"
)
return [selectionUnknownChange(input)];
if (input.leftShapes.length === 0 || input.rightShapes.length === 0)
if (input.leftShapes.length === 0 && input.rightShapes.length === 0)
return [emptyShapeUnknownChange(input)];
const paired = input.pairing.pairs.flatMap((pair) => {
const left = input.leftShapes[pair.leftIndex];
@@ -278,6 +278,25 @@ describe("GraphQL operation recognition", () => {
]);
});
it("recognizes operations after leading fragment interpolations only when prior quasis are ignored", () => {
expect(
rpcEndpoints(`
const one = gql\` \${CORE_FIELDS}
query CommentsForPost($postId: ID!) { comments { id } }\`;
const two = gql\`\${CORE_FIELDS}
# another fragment
,\${MORE_FIELDS}
mutation UpdatePost($id: ID!) { updatePost(id: $id) { id } }\`;
const prose = gql\`fragment prose before interpolation
\${CORE_FIELDS}
query NotAnOperation { viewer { id } }\`;
`),
).toEqual([
{ kind: "network", value: "CommentsForPost", mechanism: "graphql:query" },
{ kind: "network", value: "UpdatePost", mechanism: "graphql:mutation" },
]);
});
it("retains operations after long ignored prefixes and long headers", () => {
const name = "Operation" + "x".repeat(512);
const text =
+22 -11
View File
@@ -163,11 +163,8 @@ const GRAPHQL_OPERATION_HEADER =
const isGraphqlIgnoredCharacter = (character: string): boolean =>
character === "," || character === "" || /\s/u.test(character);
/** Recover a named operation from GraphQL document text. */
export const graphqlOperationFromText = (
text: string,
): JavaScriptRpcEndpoint | undefined => {
// Skip GraphQL ignored tokens: whitespace, commas, BOM and # comments.
/** Return the first offset after GraphQL ignored tokens. */
const skipGraphqlIgnored = (text: string): number => {
const limit = text.length;
let index = 0;
while (index < limit) {
@@ -177,7 +174,15 @@ export const graphqlOperationFromText = (
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
} else break;
}
if (index >= limit) return undefined;
return index;
};
/** Recover a named operation from GraphQL document text. */
export const graphqlOperationFromText = (
text: string,
): JavaScriptRpcEndpoint | undefined => {
const index = skipGraphqlIgnored(text);
if (index >= text.length) return undefined;
const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index));
const type = header?.[1];
const name = header?.[2];
@@ -310,11 +315,17 @@ export const createJavaScriptRpcScan = (
? undefined
: graphqlOperationFromText(node.value);
if (t.isTemplateLiteral(node)) {
// A template recovered without a cooked value stays dynamic.
const cooked = node.quasis[0]?.value.cooked;
return cooked === undefined || cooked === null
? undefined
: graphqlOperationFromText(cooked);
// Interpolations may contain fragment definitions. Continue only
// across quasis that contain ignored tokens; prose or dynamic text
// before an operation prevents claiming a later operation belongs to
// this document prefix.
for (const quasi of node.quasis) {
const cooked = quasi.value.cooked;
if (cooked === undefined || cooked === null) return undefined;
if (skipGraphqlIgnored(cooked) === cooked.length) continue;
return graphqlOperationFromText(cooked);
}
return undefined;
}
return undefined;
},
@@ -51,6 +51,13 @@ interface CandidateIndex {
string,
readonly StaticRuntimeCandidate[]
>;
readonly byNativeDeclarationPath: ReadonlyMap<
string,
readonly {
readonly candidate: StaticRuntimeCandidate;
readonly sourcePath: string | null;
}[]
>;
readonly byLayerCategoryPath: ReadonlyMap<
string,
readonly StaticRuntimeCandidate[]
@@ -170,9 +177,25 @@ const candidatesWithLocation = (
const located = new Map<string, LocatedCandidate>();
for (const layer of layers)
for (const mapping of mappedRuntimePaths(layer, entity))
for (const candidate of index.byLayerCategoryPath.get(
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
) ?? []) {
for (const candidate of [
...(index.byLayerCategoryPath.get(
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
) ?? []),
...(mapping.fileStyle === null || mapping.nativeRelativePath === null
? []
: (
index.byNativeDeclarationPath.get(
`${mapping.fileStyle}\0${layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.nativeRelativePath)}`,
) ?? []
)
.filter(
({ sourcePath }) =>
mapping.artifactPrefix === "" ||
(sourcePath !== null &&
sourcePath.startsWith(`${mapping.artifactPrefix}/`)),
)
.map(({ candidate }) => candidate)),
]) {
const key = candidateKey(candidate);
if (!located.has(key)) located.set(key, { candidate, mapping });
}
@@ -189,6 +212,10 @@ const indexCandidates = (
): CandidateIndex => {
const byCategoryDigest = new Map<string, StaticRuntimeCandidate[]>();
const byLayerCategoryPath = new Map<string, StaticRuntimeCandidate[]>();
const byNativeDeclarationPath = new Map<
string,
{ candidate: StaticRuntimeCandidate; sourcePath: string | null }[]
>();
for (const candidate of candidates) {
for (const { sha256 } of candidate.digests)
appendIndex(
@@ -202,14 +229,23 @@ const indexCandidates = (
layerCategoryPathKey(candidate.layer.layerId, candidate.category, path),
candidate,
);
for (const style of ["posix", "windows"] as const)
for (const { path, sourcePath } of candidate.nativeDeclarationPaths[
style
])
appendIndex(
byNativeDeclarationPath,
`${style}\0${layerCategoryPathKey(candidate.layer.layerId, candidate.category, path)}`,
{ candidate, sourcePath },
);
}
return { byCategoryDigest, byLayerCategoryPath };
return { byCategoryDigest, byLayerCategoryPath, byNativeDeclarationPath };
};
const appendIndex = (
index: Map<string, StaticRuntimeCandidate[]>,
const appendIndex = <Value>(
index: Map<string, Value[]>,
key: string,
candidate: StaticRuntimeCandidate,
candidate: Value,
): void => {
const values = index.get(key);
if (values === undefined) index.set(key, [candidate]);
@@ -1,6 +1,7 @@
import { z } from "zod";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { logicalPathEscapesRoot } from "../artifactIdentity.js";
import { evidenceSchema } from "../evidence.js";
import { javascriptRuntimeUnresolvedLocationSchema } from "./javascriptRuntimeObservation.js";
import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js";
@@ -16,8 +17,7 @@ const safePrefixSchema = z
.refine(
(value) =>
value === "" ||
(!value.startsWith("/") &&
!value.includes("\\") &&
(!logicalPathEscapesRoot(value) &&
value.split("/").every((part) => !["", ".", ".."].includes(part))),
"Artifact prefix must be empty or a safe relative POSIX path",
);
@@ -16,6 +16,12 @@ export interface StaticRuntimeCandidate {
readonly node: ApplicationNode;
readonly category: "renderer" | "javascript" | "worker" | "service-worker";
readonly paths: readonly string[];
readonly nativeDeclarationPaths: Readonly<
Record<
"posix" | "windows",
readonly { readonly path: string; readonly sourcePath: string | null }[]
>
>;
readonly digests: readonly {
readonly sha256: string;
readonly basis: "content-sha256" | "module-source-sha256";
@@ -24,6 +30,9 @@ export interface StaticRuntimeCandidate {
export interface MappedRuntimePath {
readonly path: string;
readonly fileStyle: "posix" | "windows" | null;
readonly nativeRelativePath: string | null;
readonly artifactPrefix: string;
readonly basis:
| "artifact-path"
| "operator-file-mapping"
@@ -54,9 +63,20 @@ export const mappedRuntimePaths = (
entity: RuntimeReconciliationEntity,
): MappedRuntimePath[] => {
const paths: MappedRuntimePath[] = [];
const fileStyle =
entity.location.kind === "file"
? absoluteFilePathStyle(entity.location.value)
: null;
if (entity.location.kind === "file" && layer.result.format === "directory") {
const path = pathBelowRoot(layer.result.input_path, entity.location.value);
if (path !== null) paths.push({ path, basis: "artifact-path" });
if (path !== null)
paths.push({
path,
fileStyle,
nativeRelativePath: path,
artifactPrefix: "",
basis: "artifact-path",
});
}
for (const mapping of layer.runtimeMappings) {
const suffix =
@@ -70,6 +90,9 @@ export const mappedRuntimePaths = (
if (path === null) continue;
paths.push({
path,
fileStyle,
nativeRelativePath: mapping.kind === "file-root" ? suffix : null,
artifactPrefix: normalizeArtifactPrefix(mapping.artifact_prefix),
basis:
mapping.kind === "file-root"
? "operator-file-mapping"
@@ -111,6 +134,7 @@ const candidatesForLayer = (
node,
category,
paths: pathsByNodeId.get(node.node_id) ?? [],
nativeDeclarationPaths: nativeDeclarationPaths(node),
digests: candidateDigests(node),
},
];
@@ -157,7 +181,9 @@ const indexStaticPaths = (
node.node_id,
uniqueSorted([
...nodePaths(node),
...(relatedBySource.get(node.node_id) ?? []).flatMap(nodePaths),
...(usesApplicationFileDeclaration(node)
? []
: (relatedBySource.get(node.node_id) ?? []).flatMap(nodePaths)),
]),
]),
);
@@ -168,9 +194,21 @@ const nodePaths = (node: ApplicationNode): string[] => {
if (node.identity.strategy === "canonical-path")
paths.push(node.identity.path);
for (const observation of node.observations) {
for (const property of ["path", "resolved_path", "declared_path"])
if (typeof observation.properties[property] === "string")
paths.push(observation.properties[property]);
const rawApplicationFile =
node.kind === "electron-renderer" &&
isRawApplicationFileObservation(observation);
if (typeof observation.properties.path === "string")
paths.push(observation.properties.path);
if (rawApplicationFile) continue;
if (typeof observation.properties.resolved_path === "string")
paths.push(observation.properties.resolved_path);
const declaration = observation.properties.declared_path;
if (
!hasOwnedArtifactPath(node) &&
typeof declaration === "string" &&
!declaration.includes("\\")
)
paths.push(declaration);
if (
observation.evidence.location.available &&
observation.evidence.location.value.kind === "artifact-path"
@@ -184,6 +222,95 @@ const nodePaths = (node: ApplicationNode): string[] => {
);
};
// Raw filesystem expressions have host syntax only when a captured file location
// establishes that format. Genuinely owned artifact facts take precedence.
const hasOwnedArtifactPath = (node: ApplicationNode): boolean =>
node.identity.strategy === "canonical-path" ||
node.observations.some(
({ properties }) => typeof properties.path === "string",
) ||
node.observations.some(
(observation) =>
!isRawApplicationFileObservation(observation) &&
typeof observation.properties.resolved_path === "string",
);
// loadFile receives a raw literal here: the syntax producer does not retain
// computed __dirname expressions. Electron resolves this relative to the app
// root, so its source-relative inference is not an owned runtime target.
const isRawApplicationFileObservation = (
observation: ApplicationNode["observations"][number],
): boolean =>
observation.properties.resolution_context === "filesystem-expression" &&
typeof observation.properties.mechanism === "string" &&
observation.properties.mechanism.startsWith("call:") &&
observation.properties.mechanism.endsWith("loadFile");
const usesApplicationFileDeclaration = (node: ApplicationNode): boolean =>
node.kind === "electron-renderer" &&
!hasOwnedArtifactPath(node) &&
node.observations.some(isRawApplicationFileObservation);
const nativeDeclarationPaths = (
node: ApplicationNode,
): StaticRuntimeCandidate["nativeDeclarationPaths"] => {
if (hasOwnedArtifactPath(node)) return { posix: [], windows: [] };
const declarations = node.observations.flatMap((observation) => {
const { properties } = observation;
return properties.resolution_context === "filesystem-expression" &&
typeof properties.declared_path === "string" &&
(isRawApplicationFileObservation(observation) ||
properties.declared_path.includes("\\"))
? [
{
path: properties.declared_path,
sourcePath: nativeDeclarationSource(observation),
},
]
: [];
});
const normalized = (windows: boolean) => {
const values = declarations.flatMap(({ path, sourcePath }) => {
if (windows && (win32.isAbsolute(path) || /^[a-z]:/iu.test(path)))
return [];
const normalizedPath = relativeLogicalPath(
windows ? path.replaceAll("\\", "/") : path,
);
return normalizedPath === null
? []
: [{ path: normalizedPath, sourcePath }];
});
return [
...new Map(
values.map((value) => [JSON.stringify(value), value]),
).values(),
].sort(
(left, right) =>
compareUnicodeCodePoints(left.path, right.path) ||
compareUnicodeCodePoints(left.sourcePath ?? "", right.sourcePath ?? ""),
);
};
return { posix: normalized(false), windows: normalized(true) };
};
// A source anchor constrains the selected application namespace; it does not
// supply a module-relative base for the raw filesystem declaration.
const nativeDeclarationSource = (
observation: ApplicationNode["observations"][number],
): string | null => {
const location = observation.evidence.location;
if (!location.available) return null;
const source =
location.value.kind === "source-range"
? location.value.source
: location.value.kind === "artifact-path"
? location.value.path
: null;
return source === null || source === "artifact-root"
? null
: relativeLogicalPath(source);
};
const candidateDigests = (
node: ApplicationNode,
): StaticRuntimeCandidate["digests"] => {
@@ -217,7 +344,8 @@ const pathBelowRoot = (root: string, value: string): string | null => {
rootStyle === "windows"
? win32.relative(root, value)
: posix.relative(root, value);
const portableRemainder = remainder.replaceAll("\\", "/");
const portableRemainder =
rootStyle === "windows" ? remainder.replaceAll("\\", "/") : remainder;
if (
remainder === "" ||
(rootStyle === "windows" && win32.isAbsolute(remainder)) ||
@@ -288,10 +416,13 @@ const activeArtifactPrefixes = (
: false,
)
)
prefixes.add(mapping.artifact_prefix);
prefixes.add(normalizeArtifactPrefix(mapping.artifact_prefix));
return [...prefixes].sort(compareUnicodeCodePoints);
};
const normalizeArtifactPrefix = (prefix: string): string =>
prefix === "" ? "" : normalizeJoinedLogicalPath(prefix);
const pathWithinPrefix = (path: string, prefix: string): boolean =>
prefix === "" || path === prefix || path.startsWith(`${prefix}/`);
@@ -299,6 +430,7 @@ const safeArtifactPath = (prefix: string, suffix: string): string | null =>
relativeLogicalPath(prefix === "" ? suffix : posix.join(prefix, suffix));
const relativeLogicalPath = (value: string): string | null => {
// These are artifact names or already parsed runtime paths, not declarations.
const normalized = normalizeJoinedLogicalPath(value);
return normalized !== "" &&
normalized !== "." &&
@@ -312,10 +444,24 @@ const uniqueMappedPaths = (
): MappedRuntimePath[] =>
[
...new Map(
values.map((value) => [`${value.basis}:${value.path}`, value]),
values.map((value) => [
JSON.stringify([
value.basis,
value.path,
value.fileStyle,
value.nativeRelativePath,
value.artifactPrefix,
]),
value,
]),
).values(),
].sort(
(left, right) =>
compareUnicodeCodePoints(left.path, right.path) ||
compareUnicodeCodePoints(left.basis, right.basis),
compareUnicodeCodePoints(left.basis, right.basis) ||
compareUnicodeCodePoints(
left.nativeRelativePath ?? "",
right.nativeRelativePath ?? "",
) ||
compareUnicodeCodePoints(left.artifactPrefix, right.artifactPrefix),
);
@@ -93,10 +93,7 @@ const resolveJavaScriptSourceMapPath = (
const windowsPath = /^[a-z]:\//iu.test(portable) || source.startsWith("\\\\");
// Preserve parent traversal rather than letting URL resolution clamp at '/'.
const anchor = `/${"source/".repeat(portable.split("/").length + 1)}`;
const directory = posix.join(
anchor,
posix.dirname(mapPath.replaceAll("\\", "/")),
);
const directory = posix.join(anchor, posix.dirname(mapPath));
const base = pathToFileURL(`${directory}/`, { windows: false });
const absolute = windowsPath ? windowsFileUrl(source) : URL.parse(source);
const resolved = windowsPath ? absolute : URL.parse(source, base);
@@ -116,8 +113,11 @@ const resolveJavaScriptSourceMapPath = (
kind: "unresolved",
reason: "Source reference names a directory rather than a file.",
};
// Encoded separators must not manufacture a different filesystem hierarchy.
if (/%(?:2f|5c)/iu.test(resolved.pathname))
// Reject separators in declarations, not escaped literal names in the owned base.
const declaredPath = windowsPath
? (absolute?.pathname ?? "")
: (URL.parse(source, "file:///")?.pathname ?? "");
if (/%(?:2f|5c)/iu.test(declaredPath))
return {
kind: "unresolved",
reason: "Source reference contains an encoded path separator.",
@@ -170,10 +170,11 @@ const windowsFileUrl = (path: string): URL | null => {
const decodedUrlPath = (url: URL): string | null => {
try {
if (url.protocol === "file:")
return fileURLToPath(url, {
windows: url.hostname !== "" && url.hostname !== "localhost",
}).replaceAll("\\", "/");
if (url.protocol === "file:") {
const windows = url.hostname !== "" && url.hostname !== "localhost";
const path = fileURLToPath(url, { windows });
return windows ? path.replaceAll("\\", "/") : path;
}
return `${url.host}${decodeURIComponent(url.pathname)}`;
} catch (cause: unknown) {
if (cause instanceof URIError || cause instanceof TypeError) return null;
@@ -25,6 +25,7 @@ import {
inspectRouteProperty,
inspectRoleProperty,
inspectRpcNode,
indexVitePreloadDependencyTable,
} from "./javascriptStaticAnalysisCalls.js";
import {
inspectBundlerRegistration,
@@ -101,6 +102,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
{
source,
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program),
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
electronBindings: electronIdentity.bindings,
rpcScan: createJavaScriptRpcScan(source, clientRoots),
@@ -32,6 +32,7 @@ import type {
JavaScriptBundlerModule,
JavaScriptBundlerRegistration,
} from "./javascriptStaticAnalysisTypes.js";
import { JAVASCRIPT_AST_FINGERPRINT_ALGORITHM } from "./javascriptStaticAnalysisTypes.js";
/** Inspect a bundler registration call and recover its module table. */
export const inspectBundlerRegistration = (
@@ -269,7 +270,7 @@ const recoverBundlerModule = (
factory_require_name: requireName,
source_sha256: sha256Text(sourceSlice(source, factory)),
structural_fingerprint_sha256: fingerprint,
structural_fingerprint_algorithm: "babel-ast-v1",
structural_fingerprint_algorithm: JAVASCRIPT_AST_FINGERPRINT_ALGORITHM,
exports: exportsValue.values,
location: range(factory),
},
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("static callee boundary matching", () => {
it("matches complete callee members without suffix collisions", () => {
const analysis = analyzeJavaScriptStaticSource(`
win.loadFile("./window.html");
BrowserWindow.loadURL("https://app.example.test");
navigator.serviceWorker.register("./worker.js");
loadFile("./bare.html");
win?.loadURL("https://optional.example.test");
uploadFile("./upload.html");
preloadFile("./preload.html");
downloadURL("https://download.example.test");
reloadURL("https://reload.example.test");
notloadURL("https://other.example.test");
myserviceWorker.register("./not-worker.js");
`);
expect(
analysis.role_paths.map(({ path, mechanism }) => [path, mechanism]),
).toEqual([
["./window.html", "call:win.loadFile"],
["https://app.example.test", "call:BrowserWindow.loadURL"],
["./bare.html", "call:loadFile"],
["https://optional.example.test", "call:win.loadURL"],
]);
expect(
analysis.references
.filter(({ kind }) => kind === "service-worker")
.map(({ specifier }) => specifier),
).toEqual(["./worker.js"]);
expect(
analysis.endpoints
.filter(({ mechanism }) => mechanism.endsWith("loadURL"))
.map(({ value }) => value),
).toEqual(["https://app.example.test", "https://optional.example.test"]);
});
});
@@ -71,7 +71,12 @@ export const inspectCall = (
kind: "dynamic-import",
specifier: chunkLoad.specifier,
});
for (const specifier of vitePreloadDependencySpecifiers(source, node, name))
for (const specifier of vitePreloadDependencySpecifiers(
node,
name,
context.vitePreloadDependencyTable,
bindings,
))
addReference(context, { node, kind: "dynamic-import", specifier });
const worker = t.isNewExpression(node) ? node : undefined;
const workerConstructor =
@@ -94,13 +99,19 @@ export const inspectCall = (
? undefined
: workerUrlSpecifier(worker, bindings)),
});
if (name.endsWith("serviceWorker.register"))
if (
name === "serviceWorker.register" ||
name.endsWith(".serviceWorker.register")
)
addReference(context, { node, kind: "service-worker", specifier: first });
inspectEndpointCall(node, name, first, context);
inspectStorageCall(node, name, first, context);
if (first === "audio" || first === "video")
noteMediaElementPreloads(node, accumulator);
if (name.endsWith("loadFile") && first !== undefined)
if (
(name === "loadFile" || name.endsWith(".loadFile")) &&
first !== undefined
)
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
@@ -114,7 +125,7 @@ export const inspectCall = (
location: range(node),
},
});
if (name.endsWith("loadURL") && first !== undefined)
if ((name === "loadURL" || name.endsWith(".loadURL")) && first !== undefined)
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
@@ -241,28 +252,156 @@ const chunkLoadSpecifier = (
};
const vitePreloadDependencySpecifiers = (
source: string,
node: JavaScriptCallLike,
name: string,
): readonly string[] => {
if (name !== "__vite__mapDeps" || !t.isArrayExpression(node.arguments[0]))
table: readonly (string | undefined)[] | null,
bindings: JavaScriptSemanticAnalysisState,
): readonly (string | undefined)[] => {
if (
name !== "__vite__mapDeps" ||
table === null ||
!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 indexes = node.arguments[0].elements;
// Explicit cache overrides change which table the helper reads. Retain the
// call slots as unknown unless every override is statically undefined.
if (
!node.arguments
.slice(1)
.every((argument) => isExplicitDefaultArgument(argument, bindings))
)
return indexes.map(() => undefined);
return indexes.map((element) => {
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0 ? table[index] : undefined;
});
};
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 isExplicitDefaultArgument = (
argument: t.CallExpression["arguments"][number],
bindings: JavaScriptSemanticAnalysisState,
): boolean => {
if (!t.isNode(argument)) return false;
const value = unwrapJavaScriptExpression(argument).node;
return (
(t.isUnaryExpression(value) && value.operator === "void") ||
(t.isIdentifier(value, { name: "undefined" }) &&
isUnmodifiedGlobalReference(value, bindings, "undefined"))
);
};
type ViteMapDepsFunction =
| t.FunctionDeclaration
| t.FunctionExpression
| t.ArrowFunctionExpression;
/** Index the canonical Vite helper's cache initializer from parsed syntax. */
export const indexVitePreloadDependencyTable = (
program: t.Program,
): readonly (string | undefined)[] | null => {
const helpers: ViteMapDepsFunction[] = [];
visitJavaScriptNodes(program, (node) => {
if (t.isFunctionDeclaration(node) && node.id?.name === "__vite__mapDeps")
helpers.push(node);
else if (
t.isVariableDeclarator(node) &&
t.isIdentifier(node.id, { name: "__vite__mapDeps" })
) {
const init = node.init;
if (t.isFunctionExpression(init) || t.isArrowFunctionExpression(init))
helpers.push(init);
}
});
if (helpers.length !== 1) return null;
const helper = helpers[0];
if (helper === undefined) return null;
const mapParameter = helper.params[1];
const mapIdentifier = t.isAssignmentPattern(mapParameter)
? mapParameter.left
: mapParameter;
if (!t.isIdentifier(mapIdentifier)) return [];
const tables: t.ArrayExpression[] = [];
visitJavaScriptNodes(
helper,
(node) => {
if (
t.isAssignmentExpression(node) &&
node.operator === "=" &&
(t.isMemberExpression(node.left) ||
t.isOptionalMemberExpression(node.left)) &&
t.isIdentifier(node.left.object, { name: mapIdentifier.name }) &&
semanticStaticPropertyName(node.left.property, node.left.computed) ===
"f" &&
t.isArrayExpression(node.right)
)
tables.push(node.right);
},
undefined,
true,
);
if (tables.length !== 1) return [];
const table = tables[0];
if (table === undefined) return [];
const dependencies = table.elements.map((element) =>
element === null || t.isSpreadElement(element)
? undefined
: stringValue(element),
);
let escapedOrMutated = false;
visitJavaScriptNodes(
program,
(node, parent) => {
if (!t.isIdentifier(node, { name: "__vite__mapDeps" })) return;
if (
(parent === helper &&
t.isFunctionDeclaration(parent) &&
parent.id === node) ||
(t.isVariableDeclarator(parent) &&
parent.init === helper &&
parent.id === node)
)
return;
if (
(t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) &&
parent.callee === node
)
return;
escapedOrMutated = true;
},
helper,
);
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
};
const visitJavaScriptNodes = (
root: t.Node,
visit: (node: t.Node, parent: t.Node | undefined) => void,
skip?: t.Node,
skipNestedFunctions = false,
): void => {
const pending: { readonly node: t.Node; readonly parent?: t.Node }[] = [
{ node: root },
];
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) continue;
const { node, parent } = current;
if (node === skip) continue;
visit(node, parent);
if (skipNestedFunctions && node !== root && t.isFunction(node)) continue;
const keys = t.VISITOR_KEYS[node.type] ?? [];
for (const key of keys) {
const child = (node as unknown as Record<string, unknown>)[key];
if (Array.isArray(child)) {
for (let index = child.length - 1; index >= 0; index -= 1) {
const item = child[index];
if (t.isNode(item)) pending.push({ node: item, parent: node });
}
} else if (t.isNode(child)) pending.push({ node: child, parent: node });
}
}
};
const inspectEndpointCall = (
@@ -292,8 +431,9 @@ const inspectEndpointCall = (
addEndpoint(context, {
node,
kind: "network",
value: endpoint,
value: endpoint.value,
mechanism: `call:${name}`,
...(endpoint.kind === "candidate" ? { interpretation: "candidate" } : {}),
});
};
@@ -401,12 +541,15 @@ export const addReference = (
const addEndpoint = (context: FindingContext, input: EndpointInput): void =>
addLocatedFinding(context, {
collection: context.accumulator.endpoints,
key: `endpoint\0${input.kind}\0${input.value}`,
key: `endpoint\0${input.kind}\0${input.interpretation ?? "literal"}\0${input.value}`,
node: input.node,
value: {
kind: input.kind,
value: sanitizeEndpointCandidate(input.value),
mechanism: input.mechanism,
...(input.interpretation === undefined
? {}
: { interpretation: input.interpretation }),
module_key: null,
location: range(input.node),
},
@@ -410,13 +410,27 @@ const XHR_METHODS = new Set([
"TRACE",
]);
/** An absolute HTTP(S)/WebSocket URL, or an absolute or relative path. */
const isRequestUrlLiteral = (value: string): boolean =>
/** Legacy URL/path shapes that bypass the overloaded lookup exclusions. */
const isRequestUrlOrPathLiteral = (value: string): boolean =>
/^(?:https?|wss?):\/\//iu.test(value) ||
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../");
/** Explicit request references, classified after receiver-specific exclusions. */
const isRequestUrlReference = (value: string): boolean => {
if (
isRequestUrlOrPathLiteral(value) ||
value.startsWith("?") ||
value.startsWith("#")
)
return true;
// WHATWG accepts HTTP/WS scheme forms without authority slashes. Parse
// without a base so bare keys stay ambiguous, and keep the source spelling.
if (!/^(?:https?|wss?):/iu.test(value)) return false;
return URL.parse(value) !== null;
};
/**
* Browsing-context and document `open` take a URL or type first and a target
* name second. The name is only a spelling, since `parent` or `self` may be a
@@ -538,10 +552,20 @@ const isKeyedCollectionReceiver = (callee: t.Node): boolean => {
receiver.property,
receiver.computed,
);
return property === "headers" || property === "searchParams";
return (
property === "headers" ||
property === "headersList" ||
property === "searchParams"
);
};
/** Select the literal URL argument for recognized network callees. */
/** A literal endpoint shape or an ambiguous argument of an HTTP-method-named call. */
export interface JavaScriptEndpointArgument {
readonly kind: "endpoint" | "candidate";
readonly value: string;
}
/** Classify one literal without treating a generic method name as network identity. */
export const endpointArgument = (
name: string,
args: readonly (
@@ -552,7 +576,11 @@ export const endpointArgument = (
)[],
callee: t.Node,
receiverFact?: JavaScriptOpenReceiverFact,
): string | undefined => {
): JavaScriptEndpointArgument | undefined => {
const literal = (
value: string | undefined,
): JavaScriptEndpointArgument | undefined =>
value === undefined ? undefined : { kind: "endpoint", value };
if (receiverFact === "window") return undefined;
if (
name === "fetch" ||
@@ -561,9 +589,18 @@ export const endpointArgument = (
name === "EventSource" ||
name.endsWith(".EventSource")
)
return stringValue(args[0]);
if (name.endsWith(".open"))
return xhrOpenUrl(name, args[0], args[1], receiverFact);
return literal(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,
};
}
const method = ["get", "post", "put", "patch", "delete", "request"].find(
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
);
@@ -572,18 +609,26 @@ export const endpointArgument = (
// Only get and delete also belong to the keyed-collection APIs, whose
// keys are header, field, and parameter names rather than URLs or paths.
// Configuration `get` reads setting keys.
return value !== undefined &&
if (value === undefined) return undefined;
if (
(method === "get" || method === "delete") &&
!isRequestUrlLiteral(value) &&
!isRequestUrlOrPathLiteral(value) &&
(isKeyedCollectionReceiver(callee) ||
(method === "get" && receiverFact === "configuration"))
? undefined
: value;
(method === "get" &&
(receiverFact === "configuration" ||
receiverFact === "extension-state")))
)
return undefined;
return {
kind: isRequestUrlReference(value) ? "endpoint" : "candidate",
value,
};
}
// navigator.sendBeacon(url, data) queues a POST to its first argument.
// Beacon URLs are endpoint observations, retaining the upstream API support.
if (name === "sendBeacon" || name.endsWith(".sendBeacon"))
return stringValue(args[0]);
if (name.endsWith("loadURL")) return stringValue(args[0]);
return literal(stringValue(args[0]));
if (name === "loadURL" || name.endsWith(".loadURL"))
return literal(stringValue(args[0]));
return undefined;
};
@@ -65,6 +65,7 @@ export interface JavaScriptFindingContext {
readonly rpcScan: JavaScriptRpcScan;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
@@ -83,6 +84,7 @@ export interface JavaScriptEndpointInput {
readonly kind: JavaScriptStaticEndpoint["kind"];
readonly value: string;
readonly mechanism: string;
readonly interpretation?: "candidate";
}
/** Values required to append one module-attributable static finding. */
@@ -1,5 +1,8 @@
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
/** Token semantics shared by bundle producers and worker admission. */
export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v2";
/** Static syntax context retained for later artifact path resolution. */
export type JavaScriptStaticPathContext =
| "module-specifier"
@@ -42,6 +45,8 @@ export interface JavaScriptStaticEndpoint {
readonly kind: "route" | "network";
readonly value: string;
readonly mechanism: string;
/** An overloaded callee name suggests this value; its network receiver is unproven. */
readonly interpretation?: "candidate";
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
@@ -75,7 +80,7 @@ interface JavaScriptBundlerModuleState {
/** One exact module factory recovered from a Webpack/Rspack registration. */
export interface JavaScriptBundlerModule extends JavaScriptBundlerModuleState {
readonly structural_fingerprint_sha256: string;
readonly structural_fingerprint_algorithm: "babel-ast-v1";
readonly structural_fingerprint_algorithm: typeof JAVASCRIPT_AST_FINGERPRINT_ALGORITHM;
}
/** One statically recognized bundler chunk or module registration. */
@@ -170,6 +170,34 @@ it("invalidates a bootstrap module slot mutated through an alias", () => {
]);
});
it.each([
[
"destructuring assignment",
`[table[1]] = [function(module) { module.exports = require("other"); }];`,
],
["for-of head", `for (table[1] of replacements) {}`],
[
"wrapped assignment",
`(table as never)[1] = function(module) { module.exports = require("other"); };`,
],
])(
"invalidates a bootstrap module slot overwritten through %s",
(_name, write) => {
const values = inspectedValues(`(() => {
const modules = {
1(module) { module.exports = require("electron"); },
2(module, exports, load) { inspect(load(1)); }
};
const table = modules;
function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
${write}
load(2);
})();`);
expect(values).toHaveLength(1);
expect(values[0]).toEqual(expect.objectContaining({ kind: "unknown" }));
},
);
it.each([
`const electron=require("electron"); const ipc=electron.ipcMain; ipc.handle=()=>null; inspect(electron.ipcMain.handle);`,
`require("electron").ipcMain.handle=()=>null; inspect(require("electron").ipcMain.handle);`,
@@ -438,3 +466,55 @@ it("propagates branch guards through object properties and function calls", () =
]);
}
});
it("completes a loaded module past a very large array without stack overflow", () => {
const parsed = parseJavaScriptSource(`(() => {
const modules = {
4(module) {
module.exports = { table: [0] };
if (module) { return; }
return;
},
8(module, exports, load) { const dep = load(4); inspect(dep); }
};
function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
load(8);
})();`);
if (parsed === null) throw new Error("symbolic source fixture did not parse");
const arrays: t.ArrayExpression[] = [];
const bareReturns: t.ReturnStatement[] = [];
let inspectCall: t.CallExpression | undefined;
traverseJavaScriptAst(parsed.program, {
enter: (node) => {
if (t.isArrayExpression(node)) arrays.push(node);
if (t.isReturnStatement(node) && node.argument == null)
bareReturns.push(node);
if (
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "inspect" })
)
inspectCall = node;
},
});
for (const array of arrays)
array.elements = Array.from({ length: 1_000_000 }, () =>
t.numericLiteral(0),
);
const earliestReturn = bareReturns.toSorted(
(left, right) => (left.start ?? 0) - (right.start ?? 0),
)[0];
const inspected = inspectCall?.arguments[0];
if (earliestReturn === undefined || inspected === undefined)
throw new Error("symbolic source fixture is missing its probes");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const resolver = new JavaScriptSymbolicBindings(state, parsed.program);
const namespace = symbolicVariants(resolver.evaluate(inspected)).find(
(variant) => variant.kind === "namespace",
);
if (namespace?.kind !== "namespace")
throw new Error("loaded module namespace missing");
expect(namespace.environment.completedAt).toBe(earliestReturn);
});
@@ -1,6 +1,8 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
runtimeFunctionParameters,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
@@ -16,7 +18,6 @@ import {
modulePropertyName,
} from "./javascriptStaticAnalysisHelpers.js";
import {
childNodes,
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
@@ -105,12 +106,8 @@ export class JavaScriptSymbolicBindings {
});
traverseJavaScriptAst(program, {
enter: (node) => {
if (t.isAssignmentExpression(node))
this.#indexWrite(node.left, node.right, node);
if (t.isUpdateExpression(node))
this.#indexWrite(node.argument, node, node);
if (t.isUnaryExpression(node, { operator: "delete" }))
this.#indexWrite(node.argument, node, node);
const target = semanticWriteSiteTarget(node);
if (target !== null) this.#indexWriteSite(target, node);
if (
t.isCallExpression(node) &&
t.isMemberExpression(node.callee) &&
@@ -636,20 +633,19 @@ export class JavaScriptSymbolicBindings {
#moduleCompletionPoint(factory: t.Function): t.Node {
let earliestReturn: t.ReturnStatement | undefined;
const pending: t.Node[] = [factory.body];
while (pending.length > 0) {
const node = pending.pop();
if (!node) continue;
if (node !== factory.body && (t.isFunction(node) || t.isClass(node)))
continue;
if (
t.isReturnStatement(node) &&
(earliestReturn === undefined ||
(node.start ?? Infinity) < (earliestReturn.start ?? Infinity))
)
earliestReturn = node;
pending.push(...childNodes(node));
}
// Use the shared traversal so source-sized child lists are pushed one at
// a time. Spreading array elements or call arguments overflows the stack.
traverseJavaScriptAst(factory.body, {
skip: (node) => t.isFunction(node) || t.isClass(node),
enter: (node) => {
if (
t.isReturnStatement(node) &&
(earliestReturn === undefined ||
(node.start ?? Infinity) < (earliestReturn.start ?? Infinity))
)
earliestReturn = node;
},
});
return earliestReturn ?? factory.body;
}
@@ -943,15 +939,33 @@ export class JavaScriptSymbolicBindings {
else for (const key of invalidSlots) modules.delete(key);
}
#indexWrite(target: t.Node, value: t.Node, site: t.Node): void {
#indexWriteSite(target: t.Node, site: t.Node): void {
const direct = unwrapJavaScriptExpression(target).node;
if (
(t.isIdentifier(direct) || t.isMemberExpression(direct)) &&
!t.isForOfStatement(site) &&
!t.isForInStatement(site)
) {
this.#indexWrite(
direct,
t.isAssignmentExpression(site) ? site.right : site,
site,
);
return;
}
for (const leaf of semanticWriteTargets(target))
this.#indexWrite(leaf, site, site, false);
}
#indexWrite(target: t.Node, value: t.Node, site: t.Node, exact = true): void {
const path: string[] = [];
let root = target;
let dynamic = false;
let root = unwrapJavaScriptExpression(target).node;
let dynamic = !exact;
while (t.isMemberExpression(root)) {
const key = semanticStaticPropertyKey(root.property, root.computed);
if (key === null) dynamic = true;
else path.unshift(key);
root = root.object;
root = unwrapJavaScriptExpression(root.object).node;
}
if (dynamic && t.isIdentifier(root)) {
const binding = resolveSemanticBindingState(this.state, root, root.name);
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
describe("Vite preload dependency recovery", () => {
it("decodes helper table values and preserves sparse and dynamic slots", () => {
const source = String.raw`
const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./caf\u00e9.js", , getDependency(), "./last.js"]))) =>
indexes.map((index) => dependencies[index]);
const dynamicIndex = chooseIndex();
__vite__mapDeps([0, , 3, dynamicIndex]);
`;
const analysis = analyzeJavaScriptStaticSource(source);
const references = analysis.references.filter(
({ kind }) => kind === "dynamic-import",
);
expect(references.map(({ specifier }) => specifier).sort()).toEqual([
"./café.js",
"./last.js",
null,
]);
expect(references.every(({ location }) => location.start.line === 6)).toBe(
true,
);
expect(references.filter(({ specifier }) => specifier === null)).toEqual([
expect.objectContaining({
expression: expect.stringContaining(
"__vite__mapDeps([0, , 3, dynamicIndex])",
),
}),
]);
});
it("does not take dependency strings from comments or unrelated helpers", () => {
const analysis = analyzeJavaScriptStaticSource(`
// cache.f = ["./comment.js"]
const unrelated = (cache) => cache.f || (cache.f = ["./unrelated.js"]);
__vite__mapDeps([0]);
`);
expect(
analysis.references.filter(({ kind }) => kind === "dynamic-import"),
).toEqual([]);
});
it("keeps cache escapes and explicit cache overrides unknown", () => {
const helper = `
const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies =
(map.f || (map.f = ["./original.js"]))) =>
indexes.map((index) => dependencies[index]);
`;
const escaped = analyzeJavaScriptStaticSource(`
${helper}
const dependencies = __vite__mapDeps.f;
dependencies[0] = "./changed.js";
__vite__mapDeps([0]);
`);
expect(
escaped.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual([null]);
const overridden = analyzeJavaScriptStaticSource(`
${helper}
__vite__mapDeps([0], customMap);
`);
expect(
overridden.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual([null]);
const defaulted = analyzeJavaScriptStaticSource(`
${helper}
__vite__mapDeps([0], undefined, void 0);
`);
expect(
defaulted.references
.filter(({ kind }) => kind === "dynamic-import")
.map(({ specifier }) => specifier),
).toEqual(["./original.js"]);
});
});
@@ -330,11 +330,13 @@ describe("source to bundle path syntax", () => {
});
});
it.each([
"https://example.test/src/modified.ts?revision=2#section",
"webpack://fixture/./src/modified.ts?revision=2#section",
])("still strips URL query and fragment from %s", (path) => {
const node = moduleNode("url", { path });
it.each(
[
"https://example.test/src/modified.ts?revision=2#section",
"webpack://fixture/./src/modified.ts?revision=2#section",
].flatMap((path) => ["path", "module_path"].map((key) => ({ key, path }))),
)("still strips URL query and fragment from $key $path", ({ key, path }) => {
const node = moduleNode("url", { [key]: path });
const graph = createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [node.node_id],
+42 -13
View File
@@ -188,7 +188,7 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => {
digests.add(node.identity.source_sha256);
}
if (node.identity.strategy === "canonical-path")
addPath(paths, "canonical-path", node.identity.path);
addPath(paths, "canonical-path", node.identity.path, "artifact");
for (const observation of node.observations) {
if (node.identity.strategy === "source-map-original") {
const resolution = observation.source_map_reference?.resolution;
@@ -202,8 +202,26 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => {
const sourceDigest = observation.properties.source_sha256;
if (typeof sourceDigest === "string" && isDigest(sourceDigest))
digests.add(sourceDigest);
for (const key of PATH_PROPERTIES)
addJsonPath(paths, observation.properties[key]);
for (const key of PATH_PROPERTIES) {
// A resolved declaration already has an owning artifact-path fact.
if (
key === "declared_path" &&
typeof observation.properties.resolved_path === "string"
)
continue;
addJsonPath(
paths,
observation.properties[key],
key === "resolved_path" ||
((key === "path" || key === "module_path") &&
!(
typeof observation.properties[key] === "string" &&
SCHEME_URL.test(observation.properties[key])
))
? "artifact"
: "reference",
);
}
}
}
return {
@@ -323,29 +341,40 @@ const matchingUnicodePaths = (
.map(({ value }) => value);
};
const addJsonPath = (paths: CurrentPath[], value: unknown): void => {
if (typeof value === "string") addPath(paths, "observation-path", value);
const addJsonPath = (
paths: CurrentPath[],
value: unknown,
format: "artifact" | "reference",
): void => {
if (typeof value === "string")
addPath(paths, "observation-path", value, format);
};
const addPath = (
paths: CurrentPath[],
kind: CurrentPathKind,
raw: string,
format: "artifact" | "reference",
): void => {
const value = normalizeCurrentPath(raw);
const value = normalizeCurrentPath(raw, format);
if (value !== null) paths.push({ kind, value, allowSuffix: true });
};
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//iu;
const SCHEME_URL_PREFIX = /^[a-z][a-z0-9+.-]*:\/\/+/iu;
const normalizeCurrentPath = (raw: string): string | null => {
const withoutQuery = SCHEME_URL.test(raw)
? (raw.split(/[?#]/u, 1)[0] ?? "")
: raw;
const portable = withoutQuery
.replace(SCHEME_URL_PREFIX, "")
.replaceAll("\\", "/");
const normalizeCurrentPath = (
raw: string,
format: "artifact" | "reference",
): string | null => {
const withoutQuery =
format === "reference" && SCHEME_URL.test(raw)
? (raw.split(/[?#]/u, 1)[0] ?? "")
: raw;
const portable =
format === "artifact"
? withoutQuery
: withoutQuery.replace(SCHEME_URL_PREFIX, "").replaceAll("\\", "/");
const parts: string[] = [];
for (const part of portable.split("/")) {
if (part === "" || part === ".") continue;