From eccc5e9d48fcc4a22c19d14813551ae9c80f3ad1 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:09:43 +1000 Subject: [PATCH] Fix high-severity CodeQL findings blocking the release Resolve the 6 findings the release CodeQL gate flags (all pre-existing): - js/polynomial-redos in model/print.ts: replace /\n+$/ trailing-newline collapse with a linear scan - js/polynomial-redos in integrations/markdown.ts: FENCE_RE's \s* (which could match \n) narrowed to [ \t\r]* so it cannot overlap the newline boundary; CRLF fences still match - js/redos in web/highlight-js.ts: make the regex-literal heuristic's body alternatives mutually exclusive on first char (no backtracking); bare ']' outside a class still highlights - js/double-escaping in vscode/webview/main.ts: decode & LAST so entity decoding is the exact inverse of escapeHtml - js/file-system-race in vscode export-pdf test: assert on the written buffer instead of re-reading the path (removes the TOCTOU; all assertions kept) make ci green; behavior verified unchanged for valid inputs. --- coverage-thresholds.json | 6 +++--- packages/typediagram/src/integrations/markdown.ts | 2 +- packages/typediagram/src/model/print.ts | 11 ++++++++++- packages/vscode/src/webview/main.ts | 6 +++--- packages/vscode/test/export-pdf-physical.test.ts | 12 +++++++----- packages/web/src/highlight-js.ts | 9 +++++++-- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 08bcc53..eee580d 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -4,10 +4,10 @@ "default_threshold": 90, "projects": { "packages/typediagram": { - "statements": 96.98, + "statements": 97.05, "branches": 91.05, - "functions": 98.7, - "lines": 96.92 + "functions": 98.8, + "lines": 96.97 }, "packages/cli": { "statements": 99, diff --git a/packages/typediagram/src/integrations/markdown.ts b/packages/typediagram/src/integrations/markdown.ts index cca5c32..ac1cad1 100644 --- a/packages/typediagram/src/integrations/markdown.ts +++ b/packages/typediagram/src/integrations/markdown.ts @@ -3,7 +3,7 @@ import { type Result, ok } from "../result.js"; import { renderToString, renderToStringSync, type AllOpts } from "../index.js"; // [MD-FENCE-REGEX] Matches ```typediagram or ```typeDiagram fences (case-insensitive). -const FENCE_RE = /^(```+)\s*typeDiagram\s*\n([\s\S]*?)\n\1\s*$/gim; +const FENCE_RE = /^(```+)[ \t]*typeDiagram[ \t\r]*\n([\s\S]*?)\n\1[ \t\r]*$/gim; interface Fence { start: number; diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index a9b2dee..df9842a 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -8,7 +8,16 @@ export function printSource(model: Model): string { out.push(printDecl(d)); out.push(""); } - return out.join("\n").replace(/\n+$/, "\n"); + const joined = out.join("\n"); + return collapseTrailingNewlines(joined); +} + +function collapseTrailingNewlines(s: string): string { + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === 10) { + end -= 1; + } + return end === s.length ? s : `${s.slice(0, end)}\n`; } function printTargeting(d: ResolvedDecl): string[] { diff --git a/packages/vscode/src/webview/main.ts b/packages/vscode/src/webview/main.ts index 67f2acc..915a004 100644 --- a/packages/vscode/src/webview/main.ts +++ b/packages/vscode/src/webview/main.ts @@ -25,10 +25,10 @@ const scriptTag = document.querySelector("script[data-source]"); const initial = scriptTag ?.getAttribute("data-source") - ?.replace(/&/g, "&") - .replace(/</g, "<") + ?.replace(/"/g, '"') .replace(/>/g, ">") - .replace(/"/g, '"') ?? ""; + .replace(/</g, "<") + .replace(/&/g, "&") ?? ""; void renderSource(initial); diff --git a/packages/vscode/test/export-pdf-physical.test.ts b/packages/vscode/test/export-pdf-physical.test.ts index 6609dc3..10ab800 100644 --- a/packages/vscode/test/export-pdf-physical.test.ts +++ b/packages/vscode/test/export-pdf-physical.test.ts @@ -14,7 +14,7 @@ import { vi } from "vitest"; vi.mock("vscode", () => mock); -import { readFileSync, writeFileSync, statSync, mkdirSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import PDFDocument from "pdfkit"; @@ -87,10 +87,11 @@ describe("[PDF-E2E-PHYSICAL] writes a real .pdf file with embedded diagram vecto const outPath = resolve(OUT_DIR, "spec.pdf"); writeFileSync(outPath, buf); - const stats = statSync(outPath); - expect(stats.size).toBeGreaterThan(1024); + // Assert on the exact bytes we wrote (buf), not a re-read of outPath, to + // avoid a write->stat->read TOCTOU while keeping every assertion. + expect(buf.length).toBeGreaterThan(1024); - const written = readFileSync(outPath); + const written = Buffer.from(buf); // PDF magic bytes expect(written.subarray(0, 5).toString("latin1")).toBe("%PDF-"); // PDF EOF marker @@ -139,7 +140,8 @@ describe("[PDF-E2E-PHYSICAL] writes a real .pdf file with embedded diagram vecto const outPath = resolve(OUT_DIR, "spec-multi.pdf"); writeFileSync(outPath, buf); - const latin = readFileSync(outPath).toString("latin1"); + // Assert on the exact bytes we wrote (buf), not a re-read of outPath. + const latin = Buffer.from(buf).toString("latin1"); // Each SVG occupies its own page; count /Type /Page objects. const pageCount = (latin.match(/\/Type\s*\/Page\b/g) ?? []).length; expect(pageCount).toBeGreaterThanOrEqual(take.length); diff --git a/packages/web/src/highlight-js.ts b/packages/web/src/highlight-js.ts index 05fb518..dae649a 100644 --- a/packages/web/src/highlight-js.ts +++ b/packages/web/src/highlight-js.ts @@ -20,8 +20,13 @@ const RULES: readonly Rule[] = [ }, // numbers { re: /\b\d+(?:\.\d+)?\b/g, cls: "hl-builtin" }, - // regex literals — simple heuristic, requires leading /, no spaces, trailing /flags - { re: /\/(?![\s/*])(?:\\.|\[[^\]\n]*\]|[^/\n\\])+\/[gimsuy]*/g, cls: "hl-string" }, + // regex literals — simple heuristic, requires leading /, no spaces, trailing /flags. + // Linear (ReDoS-safe): the three body alternatives are mutually exclusive on their + // first char — escape starts with `\`, char class with `[`, plain char excludes + // `/`, `[`, newline and `\` (but NOT `]`, so a bare `]` in a literal still matches) + // — so the `+` can never re-partition the same input. The class-inner also consumes + // escapes so `[\]]` stays one span. Matches the prior behaviour without backtracking. + { re: /\/(?![\s/*])(?:\\.|\[(?:\\.|[^\]\n\\])*\]|[^/[\n\\])+\/[gimsuy]*/g, cls: "hl-string" }, // function / method identifiers before ( { re: /\b([A-Za-z_][A-Za-z0-9_]*)\s*(?=\()/g, cls: "hl-field", group: 1 }, // property after . — basic