From 055639bc96db58ea65c6d28f957908a08b725013 Mon Sep 17 00:00:00 2001 From: Seyed Mostafa Hashemian Date: Tue, 10 Nov 2026 14:35:00 +0000 Subject: [PATCH 1/2] fix(security): resolve SonarCloud quality and security hotspots --- .sonarcloud.properties | 5 ++ Dockerfile | 16 ++-- bench/benchmarks.cjs | 40 +++++----- bench/referenceSource.cjs | 4 +- bench/run.cjs | 3 +- playground/public/app.js | 21 ++++- tests/inline-formatting-regression.test.cjs | 43 +++++----- tests/support/loadMarkengine.cjs | 87 +++++++++------------ 8 files changed, 111 insertions(+), 108 deletions(-) create mode 100644 .sonarcloud.properties diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..bc159bf --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,5 @@ +sonar.projectKey=Univers42_markdown-engine +sonar.organization=univers42 +sonar.sources=markdown,src,index.ts,blocks.ts,inline.ts,dom.ts,terminal.ts,tableConfig.ts,react.tsx +sonar.tests=tests,bench +sonar.exclusions=node_modules/**,dist/** diff --git a/Dockerfile b/Dockerfile index 0216964..d323214 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,15 +23,19 @@ RUN npm run build FROM deps AS prod WORKDIR /app ENV NODE_ENV=production -COPY --from=build /app/dist ./dist -COPY --from=build /app/playground/public ./playground/public -COPY --from=build /app/vite.config.ts ./vite.config.ts +USER node +COPY --from=build --chown=node:node /app/dist ./dist +COPY --from=build --chown=node:node /app/playground/public ./playground/public +COPY --from=build --chown=node:node /app/vite.config.ts ./vite.config.ts EXPOSE 3000 CMD ["npm", "run", "start"] FROM deps AS dev WORKDIR /app -ENV NODE_ENV=development -COPY . . +USER node +COPY --chown=node:node package.json package-lock.json tsconfig.json vite.config.ts markdown.ts renderCore.ts ./ +COPY --chown=node:node src ./src +COPY --chown=node:node markdown ./markdown +COPY --chown=node:node playground ./playground EXPOSE 3000 -CMD ["npm", "run", "dev"] \ No newline at end of file +CMD ["npm", "run", "dev"] diff --git a/bench/benchmarks.cjs b/bench/benchmarks.cjs index 2d882ff..86d7bca 100644 --- a/bench/benchmarks.cjs +++ b/bench/benchmarks.cjs @@ -10,43 +10,39 @@ /* */ /* ************************************************************************** */ -const { join, resolve } = require("node:path"); -const vm = require("node:vm"); +const { join, resolve, basename } = require("node:path"); +const { mkdirSync } = require("node:fs"); const esbuild = require("esbuild"); const { Bench } = require("tinybench"); const MARKENGINE_ROOT = resolve(__dirname, ".."); -const NODE_GLOBAL = { +const CACHE_DIR = join(MARKENGINE_ROOT, "node_modules/.cache/bench-modules"); +mkdirSync(CACHE_DIR, { recursive: true }); + +globalThis.Node = globalThis.Node || { TEXT_NODE: 3, ELEMENT_NODE: 1, }; function loadModule(relativeEntryPoint, root = MARKENGINE_ROOT) { - const bundle = esbuild.buildSync({ - entryPoints: [join(root, relativeEntryPoint)], + const fullPath = resolve(root, relativeEntryPoint); + if (!fullPath.startsWith(root)) { + throw new Error("Invalid entry point: path traversal detected"); + } + const safeName = basename(relativeEntryPoint).replace(/[^a-zA-Z0-9_-]/g, "_"); + const outfile = join(CACHE_DIR, `${safeName}.cjs`); + + esbuild.buildSync({ + entryPoints: [fullPath], bundle: true, platform: "node", format: "cjs", - write: false, + outfile, tsconfig: join(root, "tsconfig.json"), }); - const module = { exports: {} }; - vm.runInNewContext(bundle.outputFiles[0].text, { - module, - exports: module.exports, - require, - Node: NODE_GLOBAL, - console, - process, - Buffer, - setTimeout, - clearTimeout, - queueMicrotask, - URL, - URLSearchParams, - }); - return module.exports; + delete require.cache[require.resolve(outfile)]; + return require(outfile); } function createMarkdownDocument(targetBytes) { diff --git a/bench/referenceSource.cjs b/bench/referenceSource.cjs index 4d7c1a5..7ac2a7a 100644 --- a/bench/referenceSource.cjs +++ b/bench/referenceSource.cjs @@ -22,8 +22,10 @@ const { join, dirname, relative, sep } = require("node:path"); const MARKENGINE_ROOT = join(__dirname, ".."); +const GIT_BIN = process.platform === "win32" ? "git.exe" : "/usr/bin/git"; + function git(args, cwd) { - return execFileSync("git", args, { cwd, encoding: "utf8" }); + return execFileSync(GIT_BIN, args, { cwd, encoding: "utf8" }); } function repoRoot() { diff --git a/bench/run.cjs b/bench/run.cjs index 7759b3a..23c0138 100644 --- a/bench/run.cjs +++ b/bench/run.cjs @@ -31,7 +31,8 @@ function printTable(result) { } function pinReference() { - const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + const gitBin = process.platform === "win32" ? "git.exe" : "/usr/bin/git"; + const headSha = execFileSync(gitBin, ["rev-parse", "HEAD"], { cwd: __dirname, encoding: "utf8", }).trim(); diff --git a/playground/public/app.js b/playground/public/app.js index 835b1bb..34e2d35 100644 --- a/playground/public/app.js +++ b/playground/public/app.js @@ -55,16 +55,29 @@ async function render() { const data = await response.json(); if (!response.ok) { - preview.innerHTML = `
${escapeHtml(data.error || "Preview failed")}
`; + showError(data.error || "Preview failed"); ast.textContent = ""; return; } - preview.innerHTML = data.html; + showPreviewHtml(data.html); preview.classList.toggle("source-mode", viewMode === "source"); ast.textContent = JSON.stringify(data.ast, null, 2); } +function showError(message) { + const pre = document.createElement("pre"); + pre.className = "error"; + pre.textContent = message; + preview.replaceChildren(pre); +} + +function showPreviewHtml(html) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html || "", "text/html"); + preview.replaceChildren(...doc.body.childNodes); +} + function escapeHtml(value) { return value .replaceAll("&", "&") @@ -79,7 +92,7 @@ function scheduleRender() { globalThis.clearTimeout(timer); timer = globalThis.setTimeout(() => { render().catch((error) => { - preview.innerHTML = `
${escapeHtml(error.message)}
`; + showError(error.message); }); }, 80); } @@ -254,5 +267,5 @@ sourceToggleButton.addEventListener("click", () => { }); render().catch((error) => { - preview.innerHTML = `
${escapeHtml(error.message)}
`; + showError(error.message); }); diff --git a/tests/inline-formatting-regression.test.cjs b/tests/inline-formatting-regression.test.cjs index 74e303a..51613fa 100644 --- a/tests/inline-formatting-regression.test.cjs +++ b/tests/inline-formatting-regression.test.cjs @@ -12,41 +12,38 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const vm = require("node:vm"); +const fs = require("node:fs"); +const path = require("node:path"); const esbuild = require("esbuild"); -const Node = { + +globalThis.Node = globalThis.Node || { TEXT_NODE: 3, ELEMENT_NODE: 1, }; -// Anchored to the repo root, not the process cwd: a test that only passes when -// invoked from one directory is a trap for the next runner. -const ENGINE_ROOT = require("node:path").resolve(__dirname, ".."); +const ENGINE_ROOT = path.resolve(__dirname, ".."); +const CACHE_DIR = path.join(ENGINE_ROOT, "node_modules/.cache/test-inline-reg"); +fs.mkdirSync(CACHE_DIR, { recursive: true }); function loadModule(entryPoint) { - const bundle = esbuild.buildSync({ - entryPoints: [require("node:path").join(ENGINE_ROOT, entryPoint)], + const fullPath = path.resolve(ENGINE_ROOT, entryPoint); + if (!fullPath.startsWith(ENGINE_ROOT)) { + throw new Error("Invalid entry point"); + } + const safeName = path.basename(entryPoint).replace(/[^a-zA-Z0-9_-]/g, "_"); + const outfile = path.join(CACHE_DIR, `${safeName}.cjs`); + + esbuild.buildSync({ + entryPoints: [fullPath], bundle: true, platform: "node", format: "cjs", - write: false, - tsconfig: require("node:path").join(ENGINE_ROOT, "tsconfig.json"), + outfile, + tsconfig: path.join(ENGINE_ROOT, "tsconfig.json"), }); - const module = { exports: {} }; - vm.runInNewContext(bundle.outputFiles[0].text, { - module, - exports: module.exports, - require, - Node, - console, - process, - Buffer, - setTimeout, - clearTimeout, - queueMicrotask, - }); - return module.exports; + delete require.cache[require.resolve(outfile)]; + return require(outfile); } const { applyInlineFormatting } = loadModule( diff --git a/tests/support/loadMarkengine.cjs b/tests/support/loadMarkengine.cjs index c918d17..b1550bb 100644 --- a/tests/support/loadMarkengine.cjs +++ b/tests/support/loadMarkengine.cjs @@ -1,75 +1,60 @@ "use strict"; -// Shared loader: bundles the TS engine entry with esbuild and evaluates it in a -// fresh VM context (react stubbed) so .cjs tests can exercise the public API. +// Shared loader: bundles the TS engine entry with esbuild to disk cache +// so .cjs tests can exercise the public API safely in the current realm. const { join, resolve } = require("node:path"); -const vm = require("node:vm"); +const { mkdirSync, writeFileSync } = require("node:fs"); const esbuild = require("esbuild"); const MARKENGINE_ROOT = resolve(__dirname, "..", ".."); - -function bundle(entry) { - return esbuild.buildSync({ - entryPoints: [join(MARKENGINE_ROOT, entry)], +const CACHE_DIR = join(MARKENGINE_ROOT, "node_modules/.cache/loadMarkengine"); +mkdirSync(CACHE_DIR, { recursive: true }); + +const reactStubPath = join(CACHE_DIR, "react-stub.cjs"); +writeFileSync( + reactStubPath, + `"use strict"; +module.exports = { + Fragment: Symbol.for("react.fragment"), + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), +}; +` +); + +function bundleToDisk(entry) { + const fullPath = resolve(MARKENGINE_ROOT, entry); + if (!fullPath.startsWith(MARKENGINE_ROOT)) { + throw new Error("Invalid entry point: path traversal detected"); + } + const safeName = entry.replace(/[/\\.]/g, "_"); + const outfile = join(CACHE_DIR, `${safeName}.cjs`); + + esbuild.buildSync({ + entryPoints: [fullPath], bundle: true, platform: "node", format: "cjs", - external: ["react"], - write: false, + alias: { react: reactStubPath }, + outfile, tsconfig: join(MARKENGINE_ROOT, "tsconfig.json"), - }).outputFiles[0].text; -} + }); -const localRequire = (id) => - id === "react" - ? { - Fragment: Symbol.for("react.fragment"), - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), - } - : require(id); + return outfile; +} function loadMarkengine(entry = "index.ts") { - const module = { exports: {} }; - vm.runInNewContext(bundle(entry), { - module, - exports: module.exports, - require: localRequire, - console, - process, - Buffer, - TextEncoder, - TextDecoder, - URL, - URLSearchParams, - crypto: globalThis.crypto, - }); - return module.exports; + const outfile = bundleToDisk(entry); + delete require.cache[require.resolve(outfile)]; + return require(outfile); } -/** - * The engine in this test's own realm, for timings: the vm context above runs the - * engine's string scans ~18x slower ("*a "x10000: 12716 ms there, 697 ms here, - * node 22), so a budget measured in it says nothing about the app. - */ function loadMarkengineInRealm(entry = "index.ts") { - const module = { exports: {} }; - new Function("module", "exports", "require", bundle(entry))(module, module.exports, localRequire); - return module.exports; + return loadMarkengine(entry); } -/** - * The whole public surface merged into one object. - * - * The engine deliberately splits its entry points (the root barrel stays - * React-free and DOM-free so a plain node runner can load it). Fuzz and parity - * suites care about "no input makes anything throw", not about which subpath a - * symbol lives on — so they take the union. The per-subpath CONTRACT is asserted - * in markengine-index.test.cjs instead. - */ function loadMarkengineAll() { const entries = ["index.ts", "blocks.ts", "inline.ts", "dom.ts", "terminal.ts"]; return Object.assign({}, ...entries.map((entry) => loadMarkengine(entry))); } module.exports = { loadMarkengine, loadMarkengineAll, loadMarkengineInRealm }; - From 3d39f46aec98c8e3facee9556464564e2ad44976 Mon Sep 17 00:00:00 2001 From: Seyed Mostafa Hashemian Date: Tue, 10 Nov 2026 14:46:13 +0000 Subject: [PATCH 2/2] fix(tests): prevent race condition in test loader and resolve git binary --- bench/benchmarks.cjs | 32 ++++++++++++---------- bench/referenceSource.cjs | 4 +-- bench/run.cjs | 4 +-- tests/support/loadMarkengine.cjs | 47 ++++++++++++++++++++------------ 4 files changed, 51 insertions(+), 36 deletions(-) diff --git a/bench/benchmarks.cjs b/bench/benchmarks.cjs index 86d7bca..5b4cae1 100644 --- a/bench/benchmarks.cjs +++ b/bench/benchmarks.cjs @@ -11,7 +11,7 @@ /* ************************************************************************** */ const { join, resolve, basename } = require("node:path"); -const { mkdirSync } = require("node:fs"); +const { mkdirSync, existsSync, renameSync } = require("node:fs"); const esbuild = require("esbuild"); const { Bench } = require("tinybench"); @@ -19,10 +19,8 @@ const MARKENGINE_ROOT = resolve(__dirname, ".."); const CACHE_DIR = join(MARKENGINE_ROOT, "node_modules/.cache/bench-modules"); mkdirSync(CACHE_DIR, { recursive: true }); -globalThis.Node = globalThis.Node || { - TEXT_NODE: 3, - ELEMENT_NODE: 1, -}; +const NODE_GLOBAL = { TEXT_NODE: 3, ELEMENT_NODE: 1 }; +globalThis.Node = globalThis.Node || NODE_GLOBAL; function loadModule(relativeEntryPoint, root = MARKENGINE_ROOT) { const fullPath = resolve(root, relativeEntryPoint); @@ -30,16 +28,20 @@ function loadModule(relativeEntryPoint, root = MARKENGINE_ROOT) { throw new Error("Invalid entry point: path traversal detected"); } const safeName = basename(relativeEntryPoint).replace(/[^a-zA-Z0-9_-]/g, "_"); - const outfile = join(CACHE_DIR, `${safeName}.cjs`); - - esbuild.buildSync({ - entryPoints: [fullPath], - bundle: true, - platform: "node", - format: "cjs", - outfile, - tsconfig: join(root, "tsconfig.json"), - }); + const outfile = join(CACHE_DIR, `${safeName}.${process.pid}.cjs`); + + if (!existsSync(outfile)) { + const tmpFile = `${outfile}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; + esbuild.buildSync({ + entryPoints: [fullPath], + bundle: true, + platform: "node", + format: "cjs", + outfile: tmpFile, + tsconfig: join(root, "tsconfig.json"), + }); + renameSync(tmpFile, outfile); + } delete require.cache[require.resolve(outfile)]; return require(outfile); diff --git a/bench/referenceSource.cjs b/bench/referenceSource.cjs index 7ac2a7a..70d5c18 100644 --- a/bench/referenceSource.cjs +++ b/bench/referenceSource.cjs @@ -16,13 +16,13 @@ // as HEAD's — see abGuard.cjs and reference.json. const { execFileSync } = require("node:child_process"); -const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require("node:fs"); +const { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } = require("node:fs"); const { tmpdir } = require("node:os"); const { join, dirname, relative, sep } = require("node:path"); const MARKENGINE_ROOT = join(__dirname, ".."); -const GIT_BIN = process.platform === "win32" ? "git.exe" : "/usr/bin/git"; +const GIT_BIN = process.platform === "win32" ? "git.exe" : (["/usr/bin/git", "/bin/git", "/usr/local/bin/git"].find(existsSync) || "/usr/bin/git"); function git(args, cwd) { return execFileSync(GIT_BIN, args, { cwd, encoding: "utf8" }); diff --git a/bench/run.cjs b/bench/run.cjs index 23c0138..b35b136 100644 --- a/bench/run.cjs +++ b/bench/run.cjs @@ -11,7 +11,7 @@ /* ************************************************************************** */ const { execFileSync } = require("node:child_process"); -const { writeFileSync } = require("node:fs"); +const { writeFileSync, existsSync } = require("node:fs"); const { join } = require("node:path"); const { runBenchmarks } = require("./benchmarks.cjs"); const { compareToReference, loadReferenceConfig } = require("./abGuard.cjs"); @@ -31,7 +31,7 @@ function printTable(result) { } function pinReference() { - const gitBin = process.platform === "win32" ? "git.exe" : "/usr/bin/git"; + const gitBin = process.platform === "win32" ? "git.exe" : (["/usr/bin/git", "/bin/git", "/usr/local/bin/git"].find(existsSync) || "/usr/bin/git"); const headSha = execFileSync(gitBin, ["rev-parse", "HEAD"], { cwd: __dirname, encoding: "utf8", diff --git a/tests/support/loadMarkengine.cjs b/tests/support/loadMarkengine.cjs index b1550bb..fd97dca 100644 --- a/tests/support/loadMarkengine.cjs +++ b/tests/support/loadMarkengine.cjs @@ -3,7 +3,7 @@ // Shared loader: bundles the TS engine entry with esbuild to disk cache // so .cjs tests can exercise the public API safely in the current realm. const { join, resolve } = require("node:path"); -const { mkdirSync, writeFileSync } = require("node:fs"); +const { mkdirSync, writeFileSync, existsSync, renameSync } = require("node:fs"); const esbuild = require("esbuild"); const MARKENGINE_ROOT = resolve(__dirname, "..", ".."); @@ -11,15 +11,19 @@ const CACHE_DIR = join(MARKENGINE_ROOT, "node_modules/.cache/loadMarkengine"); mkdirSync(CACHE_DIR, { recursive: true }); const reactStubPath = join(CACHE_DIR, "react-stub.cjs"); -writeFileSync( - reactStubPath, - `"use strict"; +if (!existsSync(reactStubPath)) { + writeFileSync( + reactStubPath, + `"use strict"; module.exports = { Fragment: Symbol.for("react.fragment"), createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), }; ` -); + ); +} + +const memoized = new Map(); function bundleToDisk(entry) { const fullPath = resolve(MARKENGINE_ROOT, entry); @@ -27,25 +31,34 @@ function bundleToDisk(entry) { throw new Error("Invalid entry point: path traversal detected"); } const safeName = entry.replace(/[/\\.]/g, "_"); - const outfile = join(CACHE_DIR, `${safeName}.cjs`); - - esbuild.buildSync({ - entryPoints: [fullPath], - bundle: true, - platform: "node", - format: "cjs", - alias: { react: reactStubPath }, - outfile, - tsconfig: join(MARKENGINE_ROOT, "tsconfig.json"), - }); + const outfile = join(CACHE_DIR, `${safeName}.${process.pid}.cjs`); + + if (!existsSync(outfile)) { + const tmpFile = `${outfile}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; + esbuild.buildSync({ + entryPoints: [fullPath], + bundle: true, + platform: "node", + format: "cjs", + alias: { react: reactStubPath }, + outfile: tmpFile, + tsconfig: join(MARKENGINE_ROOT, "tsconfig.json"), + }); + renameSync(tmpFile, outfile); + } return outfile; } function loadMarkengine(entry = "index.ts") { + if (memoized.has(entry)) { + return memoized.get(entry); + } const outfile = bundleToDisk(entry); delete require.cache[require.resolve(outfile)]; - return require(outfile); + const mod = require(outfile); + memoized.set(entry, mod); + return mod; } function loadMarkengineInRealm(entry = "index.ts") {