diff --git a/packages/scanner/src/__tests__/matchers.test.ts b/packages/scanner/src/__tests__/matchers.test.ts
index 696b52a..68bf7ef 100644
--- a/packages/scanner/src/__tests__/matchers.test.ts
+++ b/packages/scanner/src/__tests__/matchers.test.ts
@@ -59,6 +59,29 @@ describe("xss matcher", () => {
const slugs = matches.map((m) => m.matchedPattern);
expect(slugs).toContain("dangerouslySetInnerHTML");
});
+
+ it("detects template interpolation mixed with HTML", () => {
+ const matches = xssMatcher.match(
+ "const unsafe = `
${body}
`;",
+ "src/components/comment.tsx",
+ );
+ expect(matches.map((m) => m.matchedPattern)).toContain("template literal in HTML");
+ });
+
+ it("does not catastrophically backtrack on long generated HTML report lines", () => {
+ const longJsonLine = `app.report = ${JSON.stringify({
+ files: Object.fromEntries(
+ Array.from({ length: 3000 }, (_, i) => [
+ `packages/example-${i}/src/file.ts`,
+ { language: "typescript", mutants: [{ id: String(i), replacement: "${user.value}" }] },
+ ]),
+ ),
+ })};`;
+ const started = performance.now();
+ const matches = xssMatcher.match(longJsonLine, "reports/mutation/packages-acp/mutation.html");
+ expect(performance.now() - started).toBeLessThan(250);
+ expect(matches).toEqual([]);
+ });
});
describe("rce matcher", () => {
diff --git a/packages/scanner/src/matchers/xss.ts b/packages/scanner/src/matchers/xss.ts
index 9a1bd1c..7ba7a87 100644
--- a/packages/scanner/src/matchers/xss.ts
+++ b/packages/scanner/src/matchers/xss.ts
@@ -1,24 +1,97 @@
+import type { CandidateMatch } from "@deepsec/core";
import type { MatcherPlugin } from "../types.js";
import { regexMatcher } from "./utils.js";
+function contextSnippet(lines: string[], lineIndex: number): string {
+ const start = Math.max(0, lineIndex - 2);
+ const end = Math.min(lines.length, lineIndex + 3);
+ return lines.slice(start, end).join("\n");
+}
+
+function hasHtmlTagAfter(line: string, start: number): boolean {
+ for (let i = start; i < line.length - 1; i++) {
+ if (line.charCodeAt(i) !== 60 /* < */) continue;
+ const next = line.charCodeAt(i + 1);
+ const afterSlash = next === 47 /* / */ ? line.charCodeAt(i + 2) : next;
+ if ((afterSlash >= 65 && afterSlash <= 90) || (afterSlash >= 97 && afterSlash <= 122))
+ return true;
+ }
+ return false;
+}
+
+function hasInterpolationInsideHtmlTag(line: string): boolean {
+ let searchFrom = 0;
+ while (searchFrom < line.length) {
+ const tagStart = line.indexOf("<", searchFrom);
+ if (tagStart === -1) return false;
+
+ const next = line.charCodeAt(tagStart + 1);
+ const afterSlash = next === 47 /* / */ ? line.charCodeAt(tagStart + 2) : next;
+ if (!((afterSlash >= 65 && afterSlash <= 90) || (afterSlash >= 97 && afterSlash <= 122))) {
+ searchFrom = tagStart + 1;
+ continue;
+ }
+
+ const interpolation = line.indexOf("${", tagStart + 1);
+ if (interpolation === -1) return false;
+
+ const tagEnd = line.indexOf(">", tagStart + 1);
+ if (tagEnd === -1) return true;
+ if (interpolation < tagEnd) return true;
+
+ searchFrom = tagEnd + 1;
+ }
+ return false;
+}
+
+function findTemplateLiteralHtml(content: string): CandidateMatch[] {
+ const lines = content.split("\n");
+ const hitLines: number[] = [];
+ let snippet = "";
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const interpolation = line.indexOf("${");
+ if (interpolation === -1) continue;
+
+ if (hasHtmlTagAfter(line, interpolation + 2) || hasInterpolationInsideHtmlTag(line)) {
+ hitLines.push(i + 1);
+ if (!snippet) snippet = contextSnippet(lines, i);
+ }
+ }
+
+ return hitLines.length > 0
+ ? [
+ {
+ vulnSlug: "xss",
+ lineNumbers: hitLines,
+ snippet,
+ matchedPattern: "template literal in HTML",
+ },
+ ]
+ : [];
+}
+
export const xssMatcher: MatcherPlugin = {
noiseTier: "normal" as const,
slug: "xss",
description: "Unsafe innerHTML, dangerouslySetInnerHTML, template injection patterns",
filePatterns: ["**/*.{ts,tsx,js,jsx,html,ejs,hbs}"],
match(content, _filePath) {
- return regexMatcher(
- "xss",
- [
- { regex: /dangerouslySetInnerHTML/, label: "dangerouslySetInnerHTML" },
- { regex: /\.innerHTML\s*=/, label: "innerHTML assignment" },
- { regex: /\.outerHTML\s*=/, label: "outerHTML assignment" },
- { regex: /document\.write\s*\(/, label: "document.write" },
- { regex: /\$\{.*\}.*<\/?\w+>|<\w+[^>]*\$\{/, label: "template literal in HTML" },
- { regex: /v-html\s*=/, label: "Vue v-html directive" },
- { regex: /\[innerHTML\]\s*=/, label: "Angular innerHTML binding" },
- ],
- content,
- );
+ return [
+ ...regexMatcher(
+ "xss",
+ [
+ { regex: /dangerouslySetInnerHTML/, label: "dangerouslySetInnerHTML" },
+ { regex: /\.innerHTML\s*=/, label: "innerHTML assignment" },
+ { regex: /\.outerHTML\s*=/, label: "outerHTML assignment" },
+ { regex: /document\.write\s*\(/, label: "document.write" },
+ { regex: /v-html\s*=/, label: "Vue v-html directive" },
+ { regex: /\[innerHTML\]\s*=/, label: "Angular innerHTML binding" },
+ ],
+ content,
+ ),
+ ...findTemplateLiteralHtml(content),
+ ];
},
};