641 lines
18 KiB
TypeScript
641 lines
18 KiB
TypeScript
import { parse } from "@babel/parser";
|
|
import * as t from "@babel/types";
|
|
|
|
import type { WebPageInspection } from "./browserObservation.js";
|
|
import { sanitizeBrowserUrl } from "./browserObservation.js";
|
|
import {
|
|
webBundleAnalysisSchema,
|
|
type AnalyzeWebBundleInput,
|
|
type WebBundleAnalysis,
|
|
} from "./webBundleAnalysis.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 }>;
|
|
type IncludedScript = Omit<BrowserScript, "source"> & {
|
|
readonly source: IncludedSource;
|
|
};
|
|
|
|
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;
|
|
parseFailures: number;
|
|
droppedFindings: number;
|
|
astLimitReached: boolean;
|
|
}
|
|
|
|
/** Analyze captured, explicitly approved JavaScript source without execution. */
|
|
export const analyzeCapturedWebBundle = (
|
|
inspection: WebPageInspection,
|
|
input: AnalyzeWebBundleInput,
|
|
sourceMaps: BundleObservations["source_maps"] = {
|
|
status: "not_requested",
|
|
requested: 0,
|
|
processed: 0,
|
|
dropped: 0,
|
|
dropped_script_keys: [],
|
|
items: [],
|
|
},
|
|
): WebBundleAnalysis => {
|
|
const accumulator = emptyAccumulator();
|
|
const sourceScripts = inspection.scripts.items.filter(isIncludedScript);
|
|
for (const script of sourceScripts) analyzeScript(script, input, accumulator);
|
|
const unavailable = inspection.scripts.items
|
|
.filter((script) => !script.source.included)
|
|
.map(({ script_key }) => script_key);
|
|
const sourceMapIncomplete =
|
|
sourceMaps.status === "partial" || sourceMaps.status === "unavailable";
|
|
const partial =
|
|
accumulator.parseFailures > 0 ||
|
|
unavailable.length > 0 ||
|
|
sourceMapIncomplete;
|
|
const truncated =
|
|
accumulator.astLimitReached ||
|
|
accumulator.droppedFindings > 0 ||
|
|
sourceMaps.status === "truncated";
|
|
return webBundleAnalysisSchema.parse({
|
|
schema_version: 1,
|
|
capture: {
|
|
target_url: inspection.target.url,
|
|
scripts_observed: inspection.scripts.total,
|
|
scripts_analyzed: sourceScripts.length,
|
|
source_artifacts: sourceScripts.map((script) => {
|
|
if (!script.source.included)
|
|
throw new TypeError("Filtered source changed");
|
|
const { text: _text, ...artifact } = script.source.artifact;
|
|
return { ...artifact, text_available: true };
|
|
}),
|
|
},
|
|
observations: {
|
|
chunks: {
|
|
nodes: sourceScripts.map((script) => {
|
|
if (!script.source.included)
|
|
throw new TypeError("Filtered source changed");
|
|
return {
|
|
script_key: script.script_key,
|
|
url: script.url,
|
|
artifact_sha256: script.source.artifact.sha256,
|
|
bytes: script.source.artifact.bytes,
|
|
};
|
|
}),
|
|
edges: accumulator.edges,
|
|
},
|
|
routes: accumulator.routes,
|
|
endpoints: accumulator.endpoints,
|
|
webmcp_declarations: accumulator.webMcp,
|
|
source_maps: sourceMaps,
|
|
},
|
|
inferences: accumulator.inferences,
|
|
unknowns: [
|
|
...(unavailable.length === 0
|
|
? []
|
|
: [
|
|
{
|
|
dimension: "script_source",
|
|
reason: "Source artifact was not captured within approved limits",
|
|
affected_script_keys: unavailable,
|
|
},
|
|
]),
|
|
...(accumulator.parseFailures === 0
|
|
? []
|
|
: [
|
|
{
|
|
dimension: "javascript_ast",
|
|
reason: "One or more source artifacts could not be parsed",
|
|
affected_script_keys: sourceScripts.map(
|
|
({ script_key }) => script_key,
|
|
),
|
|
},
|
|
]),
|
|
...(sourceMaps.status === "not_requested" ||
|
|
sourceMaps.status === "included"
|
|
? []
|
|
: [
|
|
{
|
|
dimension: "source_maps",
|
|
reason:
|
|
sourceMaps.status === "truncated"
|
|
? "Source-map evidence was truncated by approved limits"
|
|
: "One or more requested source maps were unavailable or incomplete",
|
|
affected_script_keys: [
|
|
...new Set([
|
|
...sourceMaps.items
|
|
.filter(({ status }) => status !== "included")
|
|
.map(({ script_key }) => script_key),
|
|
...sourceMaps.dropped_script_keys,
|
|
]),
|
|
].sort(),
|
|
},
|
|
]),
|
|
],
|
|
completeness: {
|
|
status: truncated
|
|
? "truncated"
|
|
: partial
|
|
? "partial"
|
|
: "complete_within_limits",
|
|
parsed_scripts: accumulator.parsedScripts,
|
|
parse_failures: accumulator.parseFailures,
|
|
visited_ast_nodes: accumulator.visitedNodes,
|
|
dropped_findings: accumulator.droppedFindings,
|
|
},
|
|
limitations: [
|
|
"Static bundle findings are observations or bounded inferences; REA does not execute captured JavaScript.",
|
|
"String-built routes and endpoints, encrypted configuration, and server-side behavior may remain unknown.",
|
|
"Page-declared WebMCP metadata is untrusted and is never registered or invoked as an REA tool.",
|
|
],
|
|
});
|
|
};
|
|
|
|
const analyzeScript = (
|
|
script: IncludedScript,
|
|
input: AnalyzeWebBundleInput,
|
|
accumulator: AnalysisAccumulator,
|
|
): void => {
|
|
let file: ReturnType<typeof parse>;
|
|
try {
|
|
file = parse(script.source.artifact.text, {
|
|
sourceType: "unambiguous",
|
|
errorRecovery: true,
|
|
plugins: ["jsx", "typescript"],
|
|
});
|
|
} catch {
|
|
accumulator.parseFailures += 1;
|
|
return;
|
|
}
|
|
accumulator.parsedScripts += 1;
|
|
detectVendorFingerprints(
|
|
script,
|
|
accumulator,
|
|
input.analysis_limits.max_findings,
|
|
);
|
|
t.traverseFast(file, (node) => {
|
|
accumulator.visitedNodes += 1;
|
|
if (accumulator.visitedNodes > input.analysis_limits.max_ast_nodes) {
|
|
accumulator.astLimitReached = true;
|
|
return t.traverseFast.stop;
|
|
}
|
|
inspectNode(script, node, accumulator, input.analysis_limits.max_findings);
|
|
return undefined;
|
|
});
|
|
};
|
|
|
|
const inspectNode = (
|
|
script: IncludedScript,
|
|
node: t.Node,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): void => {
|
|
if (
|
|
(t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) &&
|
|
node.source !== undefined
|
|
)
|
|
addEdge(
|
|
script,
|
|
node.source.value,
|
|
"static_import",
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
else if (t.isExportNamedDeclaration(node) && node.source != null)
|
|
addEdge(
|
|
script,
|
|
node.source.value,
|
|
"static_import",
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
else if (t.isImportExpression(node) && t.isStringLiteral(node.source))
|
|
addEdge(
|
|
script,
|
|
node.source.value,
|
|
"dynamic_import",
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
if (t.isObjectProperty(node))
|
|
inspectRouteProperty(script, node, accumulator, maximumFindings);
|
|
if (t.isCallExpression(node) || t.isNewExpression(node))
|
|
inspectCall(script, node, accumulator, maximumFindings);
|
|
};
|
|
|
|
const inspectCall = (
|
|
script: IncludedScript,
|
|
node: t.CallExpression | t.NewExpression,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): void => {
|
|
const name = calleeName(node.callee);
|
|
const first = stringArgument(node.arguments[0]);
|
|
if ((name === "require" || name.endsWith(".require")) && first !== undefined)
|
|
addEdge(script, first, "require", node, accumulator, maximumFindings);
|
|
if (
|
|
(name === "importScripts" || name.endsWith(".importScripts")) &&
|
|
first !== undefined
|
|
)
|
|
addEdge(script, first, "worker_import", node, accumulator, maximumFindings);
|
|
if (
|
|
routeCallNames.some(
|
|
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
|
|
)
|
|
) {
|
|
if (first !== undefined)
|
|
addFinding(
|
|
accumulator.routes,
|
|
script,
|
|
first,
|
|
`call:${name}`,
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
addRouteFrameworkInference(
|
|
script,
|
|
name,
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
}
|
|
const endpoint = endpointArgument(name, node.arguments);
|
|
if (endpoint !== undefined)
|
|
addFinding(
|
|
accumulator.endpoints,
|
|
script,
|
|
endpoint,
|
|
`call:${name}`,
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
if (
|
|
name.endsWith("modelContext.registerTool") ||
|
|
name === "modelContext.registerTool"
|
|
)
|
|
addWebMcpDeclaration(script, node, accumulator, maximumFindings);
|
|
};
|
|
|
|
const inspectRouteProperty = (
|
|
script: IncludedScript,
|
|
node: t.ObjectProperty,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): void => {
|
|
const key = propertyName(node.key);
|
|
if ((key === "path" || key === "route") && t.isStringLiteral(node.value))
|
|
addFinding(
|
|
accumulator.routes,
|
|
script,
|
|
node.value.value,
|
|
`property:${key}`,
|
|
node,
|
|
accumulator,
|
|
maximumFindings,
|
|
);
|
|
};
|
|
|
|
const addEdge = (
|
|
script: IncludedScript,
|
|
specifier: string,
|
|
kind: ChunkEdge["kind"],
|
|
node: t.Node,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): void => {
|
|
const bounded = specifier.slice(0, 4_096);
|
|
const key = `edge\0${script.script_key}\0${kind}\0${bounded}`;
|
|
addBounded(accumulator, key, maximumFindings, () =>
|
|
accumulator.edges.push({
|
|
from_script_key: script.script_key,
|
|
kind,
|
|
specifier: bounded,
|
|
resolved_url: resolveSpecifier(bounded, script.url),
|
|
location: location(script.script_key, node),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const addFinding = (
|
|
collection: Finding[],
|
|
script: IncludedScript,
|
|
rawValue: string,
|
|
mechanism: string,
|
|
node: t.Node,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): void => {
|
|
const value = sanitizeCandidate(rawValue);
|
|
const key = `finding\0${mechanism}\0${script.script_key}\0${value}`;
|
|
addBounded(accumulator, key, maximumFindings, () =>
|
|
collection.push({
|
|
value,
|
|
mechanism,
|
|
location: location(script.script_key, node),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const addWebMcpDeclaration = (
|
|
script: IncludedScript,
|
|
node: t.CallExpression | t.NewExpression,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): 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 declaredProperties = t.isObjectExpression(schema)
|
|
? objectValue(schema, "properties")
|
|
: undefined;
|
|
const propertyObject = t.isObjectExpression(declaredProperties)
|
|
? declaredProperties
|
|
: schema;
|
|
const schemaPropertyNames = t.isObjectExpression(propertyObject)
|
|
? propertyObject.properties.flatMap((property) =>
|
|
t.isObjectProperty(property)
|
|
? [propertyName(property.key)].filter(Boolean)
|
|
: [],
|
|
)
|
|
: [];
|
|
const key = `webmcp\0${script.script_key}\0${name ?? ""}\0${schemaPropertyNames.join("\0")}`;
|
|
addBounded(accumulator, key, maximumFindings, () =>
|
|
accumulator.webMcp.push({
|
|
name: name?.slice(0, 256) ?? null,
|
|
description: description?.slice(0, 2_048) ?? null,
|
|
schema_property_names: [...new Set(schemaPropertyNames)]
|
|
.sort()
|
|
.slice(0, 256),
|
|
trust: "page-declared-untrusted",
|
|
location: location(script.script_key, node),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const detectVendorFingerprints = (
|
|
script: IncludedScript,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): 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}`;
|
|
addBounded(accumulator, key, maximumFindings, () =>
|
|
accumulator.inferences.push({
|
|
kind: detector.kind,
|
|
value: detector.value,
|
|
confidence: detector.confidence,
|
|
basis: [basis(script, detector.detector)],
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
const addRouteFrameworkInference = (
|
|
script: IncludedScript,
|
|
name: string,
|
|
node: t.Node,
|
|
accumulator: AnalysisAccumulator,
|
|
maximumFindings: number,
|
|
): void => {
|
|
const value =
|
|
name.endsWith("useRoutes") || name.endsWith("createBrowserRouter")
|
|
? "React Router-compatible route API"
|
|
: "Generic route registration API";
|
|
const key = `route-framework\0${script.script_key}\0${value}`;
|
|
addBounded(accumulator, key, maximumFindings, () =>
|
|
accumulator.inferences.push({
|
|
kind: "route_framework",
|
|
value,
|
|
confidence: "medium",
|
|
basis: [{ ...basis(script, `call:${name}`), ...locationFields(node) }],
|
|
}),
|
|
);
|
|
};
|
|
|
|
const addBounded = (
|
|
accumulator: AnalysisAccumulator,
|
|
key: string,
|
|
maximum: number,
|
|
add: () => void,
|
|
): void => {
|
|
if (accumulator.seen.has(key)) return;
|
|
const findings =
|
|
accumulator.edges.length +
|
|
accumulator.routes.length +
|
|
accumulator.endpoints.length +
|
|
accumulator.webMcp.length +
|
|
accumulator.inferences.length;
|
|
if (findings >= maximum) {
|
|
accumulator.droppedFindings += 1;
|
|
return;
|
|
}
|
|
accumulator.seen.add(key);
|
|
add();
|
|
};
|
|
|
|
const endpointArgument = (
|
|
name: string,
|
|
args: readonly (
|
|
| t.Expression
|
|
| t.SpreadElement
|
|
| t.JSXNamespacedName
|
|
| t.ArgumentPlaceholder
|
|
)[],
|
|
): string | undefined => {
|
|
if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket")
|
|
return stringArgument(args[0]);
|
|
if (name.endsWith(".open") && stringArgument(args[1]) !== undefined)
|
|
return stringArgument(args[1]);
|
|
if (
|
|
["get", "post", "put", "patch", "delete", "request"].some(
|
|
(method) => name === method || name.endsWith(`.${method}`),
|
|
)
|
|
)
|
|
return stringArgument(args[0]);
|
|
return undefined;
|
|
};
|
|
|
|
const calleeName = (callee: t.Node): string => {
|
|
if (t.isIdentifier(callee)) return callee.name;
|
|
if (t.isImport(callee)) return "import";
|
|
if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) {
|
|
const object = t.isExpression(callee.object)
|
|
? calleeName(callee.object)
|
|
: "";
|
|
const property = propertyName(callee.property);
|
|
return object === "" ? property : `${object}.${property}`;
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const stringArgument = (
|
|
value: t.Node | null | undefined,
|
|
): string | undefined => (t.isStringLiteral(value) ? value.value : undefined);
|
|
|
|
const propertyName = (value: t.Node): string => {
|
|
if (t.isIdentifier(value)) return value.name;
|
|
if (t.isStringLiteral(value)) return value.value;
|
|
return "";
|
|
};
|
|
|
|
const objectValue = (
|
|
object: t.ObjectExpression,
|
|
name: string,
|
|
): t.ObjectProperty["value"] | undefined => {
|
|
for (const property of object.properties)
|
|
if (t.isObjectProperty(property) && propertyName(property.key) === name)
|
|
return property.value;
|
|
return undefined;
|
|
};
|
|
|
|
const objectString = (
|
|
object: t.ObjectExpression,
|
|
name: string,
|
|
): string | undefined => {
|
|
const value = objectValue(object, name);
|
|
return t.isStringLiteral(value) ? value.value : undefined;
|
|
};
|
|
|
|
const resolveSpecifier = (specifier: string, base: string): string | null => {
|
|
try {
|
|
const resolved = new URL(specifier, base);
|
|
if (resolved.protocol !== "http:" && resolved.protocol !== "https:")
|
|
return null;
|
|
return sanitizeBrowserUrl(resolved.href).url;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const sanitizeCandidate = (value: string): string => {
|
|
const bounded = value.slice(0, 4_096);
|
|
try {
|
|
const parsed = new URL(bounded, "https://rea.invalid");
|
|
const sanitized = sanitizeBrowserUrl(parsed.href).url;
|
|
return parsed.origin === "https://rea.invalid"
|
|
? sanitized.replace("https://rea.invalid", "")
|
|
: sanitized;
|
|
} catch {
|
|
return bounded.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
|
}
|
|
};
|
|
|
|
const location = (scriptKey: string, node: t.Node) => ({
|
|
script_key: scriptKey,
|
|
...locationFields(node),
|
|
});
|
|
|
|
const locationFields = (node: t.Node) => ({
|
|
line: node.loc?.start.line ?? null,
|
|
column: node.loc?.start.column ?? null,
|
|
});
|
|
|
|
const basis = (script: IncludedScript, detector: string) => ({
|
|
script_key: script.script_key,
|
|
artifact_sha256: script.source.artifact.sha256,
|
|
line: null,
|
|
column: null,
|
|
detector,
|
|
});
|
|
|
|
const emptyAccumulator = (): AnalysisAccumulator => ({
|
|
edges: [],
|
|
routes: [],
|
|
endpoints: [],
|
|
webMcp: [],
|
|
inferences: [],
|
|
seen: new Set(),
|
|
visitedNodes: 0,
|
|
parsedScripts: 0,
|
|
parseFailures: 0,
|
|
droppedFindings: 0,
|
|
astLimitReached: false,
|
|
});
|
|
|
|
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",
|
|
},
|
|
];
|