fix(javascript): report SVG script references and skipped bodies (#8627)

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
hhangandhhang authored and GitHub committed 2026-11-02 15:55:56 +08:00
1 parent f95bdbce76
commit 20a282a181
4 files changed
+175 -6

No files matched your search

+6 -1
View File
@@ -1002,7 +1002,12 @@ reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
line endings, preserving UTF-16 columns.
Inline JavaScript in local HTML, meaning classic or module `<script>` bodies
without a `src` attribute, is not analyzed. Each HTML file that has one gets an
without a `src` attribute, is not analyzed. Embedded SVG scripts use `href`
(or `xlink:href` when `href` is absent) instead: an existing reference, even
an empty one, suppresses the inline body. SVG's `src` and HTML-only `language`
attributes do not suppress its JavaScript body. SVG references and inline
bodies share the same inert parsing and source coordinates as HTML scripts;
template content stays inert. Each HTML file that has an inline body gets an
unknown `parse-html-inline-scripts` scope with the exact body count and the
script type and source range of the first 100 bodies; beyond that, coverage is
truncated under the `html_inline_script_locations_per_file` limit, and the
@@ -106,7 +106,7 @@ it.each([
['<script language="vbscript">run()</script>', []],
["<!-- <script>run();</script> -->", []],
["<template><script>run();</script></template>", []],
["<svg><script>run();</script></svg>", []],
["<svg><script>run();</script></svg>", ["classic"]],
])("classifies inline JavaScript in %s", (text, expected) => {
expect(
htmlArtifactReferences(text).inlineScripts.map(
@@ -115,6 +115,65 @@ it.each([
).toEqual(expected);
});
it.each([
['src="ignored.js"', "classic"],
['language="vbscript"', "classic"],
['xlink:type="simple"', "classic"],
['type=" Text/JavaScript "', "classic"],
['type="MODULE"', "module"],
['type=" module "', null],
['type="application/json"', null],
['type="text/javascript; charset=utf-8"', null],
['href="external.js"', null],
['href=""', null],
['xlink:href="external.js"', null],
] as const)("classifies SVG inline bodies using %s", (attributes, expected) => {
const result = htmlArtifactReferences(
`<svg><script ${attributes}>run();</script></svg>`,
);
expect(result.inlineScripts.map(({ scriptType }) => scriptType)).toEqual(
expected === null ? [] : [expected],
);
expect(result.inlineScriptCount).toBe(expected === null ? 0 : 1);
});
it.each([
['href="external.js"', ["external.js"]],
['xlink:href="external.js"', ["external.js"]],
['href="first.js" xlink:href="second.js"', ["first.js"]],
['xlink:href="second.js" href="first.js"', ["first.js"]],
['href="" xlink:href="ignored.js"', []],
['other:href="ignored.js"', []],
['src="ignored.js"', []],
])("extracts SVG script URL from %s", (attributes, paths) => {
const result = htmlArtifactReferences(
`<svg><script ${attributes}></script></svg>`,
);
expect(result.scripts.map(({ scriptPath }) => scriptPath)).toEqual(paths);
expect(result.inlineScriptCount).toBe(0);
});
it("shares precise body coordinates and location limits across HTML and SVG", () => {
const prefix = '<!doctype html>\n<svg><script src="ignored.js">';
const body = "\nrun();\n";
expect(
htmlArtifactReferences(`${prefix}${body}</script></svg>`).inlineScripts,
).toEqual([
{
scriptType: "classic",
location: { start: { line: 2, column: 30 }, end: { line: 4, column: 0 } },
},
]);
const repeated = "<script>x</script><svg><script>y</script></svg>".repeat(60);
const result = htmlArtifactReferences(repeated);
expect(result.inlineScriptCount).toBe(120);
expect(result.inlineScripts).toHaveLength(HTML_INLINE_SCRIPT_LOCATION_LIMIT);
expect(
htmlArtifactReferences("<template><svg><script>x</script></svg></template>")
.inlineScriptCount,
).toBe(0);
});
it("retains exact inline script body coordinates, including unclosed scripts", () => {
const prefix = "<!doctype html>\n<script>";
const body = "\nfetch('/api');\n";
@@ -80,6 +80,30 @@ const javaScriptType = (
const asciiLowercase = (value: string): string =>
value.replace(/[A-Z]/gu, (letter) => letter.toLowerCase());
/** SVG uses href/XLink and has no HTML language fallback or src attribute. */
const scriptAttributes = (node: DefaultTreeAdapterTypes.Element) => {
const html = node.namespaceURI === "http://www.w3.org/1999/xhtml";
const source = html
? node.attrs.find(({ name }) => name === "src")
: (node.attrs.find(
({ name, namespace }) => name === "href" && namespace === undefined,
) ??
node.attrs.find(
({ name, namespace }) =>
name === "href" && namespace === "http://www.w3.org/1999/xlink",
));
return {
source,
scriptType: javaScriptType(
html
? node.attrs
: node.attrs.filter(
({ name, namespace }) => name === "type" && namespace === undefined,
),
),
};
};
/**
* Extract inert HTML script references, inline JavaScript bodies, and the
* first document base href.
@@ -105,15 +129,19 @@ export const htmlArtifactReferences = (
if (node === undefined) continue;
if (
defaultTreeAdapter.isElementNode(node) &&
node.namespaceURI === "http://www.w3.org/1999/xhtml"
(node.namespaceURI === "http://www.w3.org/1999/xhtml" ||
node.namespaceURI === "http://www.w3.org/2000/svg")
) {
if (node.tagName === "base" && baseHref === null) {
if (
node.namespaceURI === "http://www.w3.org/1999/xhtml" &&
node.tagName === "base" &&
baseHref === null
) {
const href = node.attrs.find(({ name }) => name === "href");
if (href !== undefined) baseHref = href.value;
}
if (node.tagName === "script") {
const scriptType = javaScriptType(node.attrs);
const src = node.attrs.find(({ name }) => name === "src");
const { scriptType, source: src } = scriptAttributes(node);
const location = node.sourceCodeLocation?.startTag;
if (src !== undefined && src.value !== "" && location !== undefined) {
scripts.push({
@@ -7,6 +7,83 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"preserves SVG script references and reports skipped executable bodies",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-svg-html-cli-");
const inlineUrl = "https://example.test/svg-inline";
const html = [
"<!doctype html><svg>",
`<script src="ignored.js" language="vbscript">fetch("${inlineUrl}");</script>`,
'<script href="main.js">ignored();</script>',
'<script xlink:href="main.js">ignored();</script>',
'<script href="" xlink:href="main.js">ignored();</script>',
'<script type="application/json">{"x":1}</script>',
"</svg><template><svg><script>ignored();</script></svg></template>",
].join("\n");
await writeFile(join(root, "index.html"), html);
await writeFile(
join(root, "main.js"),
'fetch("https://example.test/external");',
);
const response = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--source-path",
"index.html",
"--source-path",
"main.js",
"--format",
"json",
],
});
expect(response.exitCode, response.stderr).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
const observations = result.graph.nodes.flatMap(
({ observations }) => observations,
);
const scopes = observations.filter(
({ properties }) => properties.operation === "parse-html-inline-scripts",
);
expect(scopes).toHaveLength(1);
expect(scopes[0]).toMatchObject({
properties: {
path: "index.html",
inline_script_count: 1,
inline_scripts: [
{
script_type: "classic",
location: {
start: { line: 2, column: 45 },
end: { line: 2, column: 86 },
},
},
],
},
evidence: {
state: "unavailable",
confidence: "unknown",
coverage: { status: "partial" },
},
});
expect(
result.graph.edges.filter(
({ relation, properties }) =>
relation === "loads" && properties.resolved_path === "main.js",
),
).toHaveLength(2);
const endpoints = result.graph.nodes
.filter(({ kind }) => kind === "endpoint")
.flatMap(({ observations }) => observations)
.map(({ properties }) => properties.value);
expect(endpoints).toContain("https://example.test/external");
expect(endpoints).not.toContain(inlineUrl);
},
);
cliTest.for([
"invalid-html",
"empty-html",