Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"build": "node build/build.mjs",
"lint": "eslint .",
"prepare": "node build/build.mjs",
"test": "node --experimental-test-module-mocks --test test/pdf-vfs.test.js"
"test": "node --experimental-test-module-mocks --test test/*.test.js"
},
"peerDependencies": {
"marked": ">=12",
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/xss-lint-covers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"_comment": [
"DS §15 — this repo's declaration of what the XSS rule covers.",
"Product source only. build/build.mjs is tooling and is globally ignored."
],
"covers": [
"src/exporters/html.js",
"src/core.js",
"src/common.js"
],
"why": "html.js is the HTML export surface (htmlPage, wordHtml, printDoc). core.js is renderHTML/escapeHtml. common.js is the shared helpers those call. Not build/build.mjs — tooling, globally ignored, and naming it would be the uploadwizard-app failure mode (a green count on files that do not render HTML)."
}
70 changes: 70 additions & 0 deletions test/fixtures/xss-lint-fixture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* TGWAB — fixture for the interpolated-JSON XSS rule. DEV-STANDARDS §15.
*
* Place at: test/fixtures/xss-lint-fixture.js
* Its test: test/xss-lint-fixture.test.js (templates/xss-lint-fixture.test.js)
*
* WHY A FIXTURE SHIPS WITH THE RULE. A rule only ever shown to pass on clean
* input has not been shown to do anything. This file is the positive control:
* five cases that MUST flag and two that MUST NOT, so a run proves the rule can
* both fire and stay silent. It is also how the selector choice was measured —
* the direct-child form catches cases 1 and 2 only.
*
* THIS FILE IS DELIBERATELY NOT LINTED BY THE REPO'S OWN RUN. The config's
* scope is `files: ['**\/*.js']`, which DOES reach this file, so what keeps it
* out is the explicit `'**\/xss-lint-fixture.js'` entry in that config's global
* ignores — named there with this reason. That is what lets `npm run lint` stay
* green with a file full of deliberate hazards in the tree; without it this
* fixture contributes five errors to every adopting repo. The test does NOT lint it where it sits — that would apply no rule at
* all — it reads this text and lints it under a synthetic src/ path so the
* repo's real scoping applies. See the test's header.
*/
/* eslint-disable no-unused-vars, no-undef -- every case here is a specimen: it
is never called, and `escapeHtml` is deliberately undeclared because the
point is the SHAPE of the call, not a working implementation. Only these two
rules are disabled, and never no-restricted-syntax — disabling that would
silence the very thing this file exists to trip. */
// Seven cases: five that MUST flag, two that MUST NOT.
// Numbered so a run's output can be read against the spec without counting.

// ---- MUST FLAG -------------------------------------------------------------

// 1. Direct child. The shape the shipped rules were written for.
function case1(n) {
return `<script>window.__DATA__ = ${JSON.stringify(n)};</script>`;
}

// 2. Direct child, no <script> in this literal — the tags come from a wrapper.
// This is the one resizewizard-api's `<script`-gated rule misses.
function case2(n) {
return `window.__DATA__ = ${JSON.stringify(n)};`;
}

// 3. Grandchild via a conditional.
function case3(n) {
return `<script>const n = ${n ? JSON.stringify(n) : 'null'};</script>`;
}

// 4. Grandchild via a wrapper call. NOT exploitable — see the template comment —
// but the same wrong pattern with the same remedy.
function case4(n) {
return `<script>const n = ${escapeHtml(JSON.stringify(n))};</script>`;
}

// 5. Grandchild via an arrow inside .map().
function case5(xs) {
return `<script>const all = [${xs.map((x) => JSON.stringify(x))}];</script>`;
}

// ---- MUST NOT FLAG ---------------------------------------------------------

// 6. The literal TEXT "JSON.stringify" inside a browser-script template. No call.
function case6() {
return `<script>console.log('use JSON.stringify here');</script>`;
}

// 7. An ordinary call, nowhere near a template literal.
function case7(x) {
const body = JSON.stringify(x);
return body;
}
178 changes: 178 additions & 0 deletions test/xss-lint-fixture.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* TGWAB — the XSS rule's own control. DEV-STANDARDS §15.
*
* Runner is node:test (this repo does not use vitest); assertions match the
* vendored kit. The specimen (`fixtures/xss-lint-fixture.js`) is byte-identical
* to tgwab-standards `templates/xss-lint-fixture.js`. Adding vitest for this
* one file would be a second runner that only the kit uses — gh-office already
* made this call.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { ESLint } from "eslint";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { basename, dirname, join, relative, resolve } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = resolve(here, "fixtures/xss-lint-fixture.js");
const COVERS = resolve(here, "fixtures/xss-lint-covers.json");

async function flaggedCases() {
const code = readFileSync(FIXTURE, "utf8");
const eslint = new ESLint();
const [result] = await eslint.lintText(code, {
filePath: resolve(process.cwd(), "src/__xss-lint-fixture__.js"),
});
const lines = code.split("\n");
const cases = new Set();
for (const m of result.messages) {
if (m.ruleId !== "no-restricted-syntax") continue;
for (let i = m.line - 1; i >= 0; i--) {
const name = /function (case\d)/.exec(lines[i]);
if (name) {
cases.add(name[1]);
break;
}
}
}
return [...cases].sort();
}

test("the interpolated-JSON XSS rule flags all five hazards (DS §15)", async () => {
const flagged = await flaggedCases();
assert.deepEqual(flagged, ["case1", "case2", "case3", "case4", "case5"]);
});

test("the interpolated-JSON XSS rule leaves the two negative controls alone", async () => {
const flagged = await flaggedCases();
assert.ok(flagged.includes("case1"));
assert.ok(!flagged.includes("case6"));
assert.ok(!flagged.includes("case7"));
});

const KIT_FILES = new Set([
"eslint.config.js",
"eslint.config.mjs",
"eslint.config.cjs",
"xss-lint-fixture.js",
"xss-lint-fixture.test.js",
]);

function* shippedFiles(dir, root) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === ".git" || entry.name === "node_modules") continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* shippedFiles(full, root);
else if (entry.isFile()) yield relative(root, full);
}
}

function carriesTheRule(config) {
const entry = config?.rules?.["no-restricted-syntax"];
if (!Array.isArray(entry) || entry[0] !== 2) return false;
return entry
.slice(1)
.some(
(o) =>
typeof o?.selector === "string" &&
o.selector.includes("JSON") &&
o.selector.includes("stringify"),
);
}

async function ruleCoverage(root = process.cwd()) {
const eslint = new ESLint();
const covered = [];
let linted = 0;
for (const rel of shippedFiles(root, root)) {
if (KIT_FILES.has(basename(rel))) continue;
if (await eslint.isPathIgnored(rel)) continue;
linted++;
if (carriesTheRule(await eslint.calculateConfigForFile(rel))) covered.push(rel);
}
return { covered, linted };
}

function repoRoot() {
let dir = here;
for (;;) {
if (existsSync(join(dir, ".git"))) return dir;
const up = dirname(dir);
if (up === dir) return null;
dir = up;
}
}

function resolveNamed(named, root) {
const fromCwd = resolve(process.cwd(), named);
if (existsSync(fromCwd)) return fromCwd;
if (root) {
const fromRoot = resolve(root, named);
if (existsSync(fromRoot)) return fromRoot;
}
return null;
}

const NOT_PRODUCT_SOURCE = [
/(^|\/)[^/]*\.config\.(js|mjs|cjs)$/,
/^(scripts|migrations|tests?|__tests__|e2e|loadtest|test-kit)\//,
];

function readDeclaration() {
let raw;
try {
raw = readFileSync(COVERS, "utf8");
} catch {
throw new Error(
`DS §15: ${relative(process.cwd(), COVERS)} is missing. Create it naming ` +
"the product source this repo ships that the rule must cover.",
);
}
const d = JSON.parse(raw);
if (!Array.isArray(d.covers)) throw new Error("DS §15: `covers` must be an array.");
return d;
}

test("the rule resolves for every file this repo NAMES as covered product source", async () => {
const d = readDeclaration();
const { covered, linted } = await ruleCoverage();
process.stdout.write(
`DS §15 coverage: the rule resolves for ${covered.length} of ${linted} ` +
`linted files; ${d.covers.length} named as product source.\n`,
);

if (d.covers.length === 0) {
assert.ok(
typeof d.declaredGap === "string" && d.declaredGap.trim().length > 0,
"DS §15: `covers` is empty, so `declaredGap` MUST say why",
);
return;
}

const eslint = new ESLint();
const root = repoRoot();
for (const named of d.covers) {
const abs = resolveNamed(named, root);
const normalized = abs && root ? relative(root, abs) : named;

assert.equal(
KIT_FILES.has(basename(named)),
false,
`DS §15: \`${named}\` is a file this repo GAINS by adopting the kit.`,
);
assert.equal(
NOT_PRODUCT_SOURCE.some((re) => re.test(normalized)),
false,
`DS §15: \`${named}\` is build tooling, not product source.`,
);
assert.ok(
abs !== null,
`DS §15: \`${named}\` is named in xss-lint-covers.json but does not exist.`,
);
assert.ok(
carriesTheRule(await eslint.calculateConfigForFile(abs)),
`DS §15: the rule does NOT resolve for \`${named}\`.`,
);
}
});