From 6c30f57a24a92f072b1a3c3eeab8cb430c76827e Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:37:43 +0300 Subject: [PATCH 1/2] chore(security): Actions permissions and test/script alerts (CodeQL) Close CodeQL alerts 1-4, 8, 37, 38, 39 and 40 on main. - ci.yml and npm-smoke.yml get a top-level `permissions: contents: read`. No step needs more: checkout reads, and setup-node, rust-cache and cache-cargo-install use the Actions cache service, not the token; nothing comments, uploads or writes checks. - release-plan.ts encodes every "/" of a package name (replaceAll). - mcp-tools-doc.ts escapes backslashes before pipes in table cells (the generated README is unchanged). - The i18n codemod decodes string literals of either quote in one pass, so an escaped backslash before a quote is never taken apart. - The fake WAE's type-error check is a linear scan (equivalence fuzz and an adversarial case), and the corrections test's tag filter is case-insensitive. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++ .github/workflows/npm-smoke.yml | 4 ++ apps/studio/test/catalog-entries.test.ts | 17 +++++++++ .../analytics/test/fake-wae-check.test.ts | 38 +++++++++++++++++++ packages/analytics/test/helpers/fake-wae.ts | 29 +++++++++++++- .../astro-renderer/test/corrections.test.ts | 2 +- scripts/i18n-codemod/catalog-entries.ts | 24 ++++++++++-- scripts/mcp-tools-doc.ts | 2 + scripts/release-plan.ts | 2 +- 9 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 apps/studio/test/catalog-entries.test.ts create mode 100644 packages/analytics/test/fake-wae-check.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab92a8d..4e668f28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: branches: [main] pull_request: +# Least privilege for the GITHUB_TOKEN: every job only reads the repository. Caches +# (setup-node, rust-cache, cache-cargo-install) use the Actions cache service, not this token; +# nothing here comments, uploads releases or writes checks. +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/npm-smoke.yml b/.github/workflows/npm-smoke.yml index 0cdb7431..db1589f7 100644 --- a/.github/workflows/npm-smoke.yml +++ b/.github/workflows/npm-smoke.yml @@ -17,6 +17,10 @@ on: - "packages/create-cloudhal/**" - "starters/**" +# Packs, installs and builds locally; the token only reads the repository. +permissions: + contents: read + jobs: smoke: runs-on: ubuntu-latest diff --git a/apps/studio/test/catalog-entries.test.ts b/apps/studio/test/catalog-entries.test.ts new file mode 100644 index 00000000..10b019dc --- /dev/null +++ b/apps/studio/test/catalog-entries.test.ts @@ -0,0 +1,17 @@ +/** Alert 37: the i18n codemod decodes string literals of either quote without breaking escapes. */ +import { describe, expect, it } from "vitest"; +import { stringBodyAsJson } from "../../../scripts/i18n-codemod/catalog-entries.ts"; + +const decode = (body: string) => JSON.parse(`"${stringBodyAsJson(body)}"`) as string; + +describe("stringBodyAsJson", () => { + it("decodes what the literal means", () => { + expect(decode("plain")).toBe("plain"); + expect(decode("it\\'s")).toBe("it's"); + expect(decode('say "hi"')).toBe('say "hi"'); + expect(decode('a\\"b')).toBe('a"b'); + expect(decode("back\\\\slash")).toBe("back\\slash"); + expect(decode('back\\\\"')).toBe('back\\"'); + expect(decode("line\\nbreak \\u00e9")).toBe("line\nbreak \u00e9"); + }); +}); diff --git a/packages/analytics/test/fake-wae-check.test.ts b/packages/analytics/test/fake-wae-check.test.ts new file mode 100644 index 00000000..29d704d0 --- /dev/null +++ b/packages/analytics/test/fake-wae-check.test.ts @@ -0,0 +1,38 @@ +/** Alert 8: the fake WAE's type-error check answers as its regex did, in linear time. */ +import { describe, expect, it } from "vitest"; +import { weightsBesideLiteralZero } from "./helpers/fake-wae.ts"; + +const old = (q: string) => /quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/.test(q); + +describe("weightsBesideLiteralZero", () => { + it("answers as the regex did", () => { + for (const q of [ + "SELECT quantileWeighted(0.5)(double1, if(blob2 = 'x', double3, 0)) FROM t", + "SELECT quantileWeighted(0.5)(double1, if(blob2 = 'x', 1, 0)) FROM t", + "SELECT quantileWeighted(0.5)(double1, _sample_interval) FROM t; SELECT if(a, double1, 0)", + "quantileWeighted(if(,double1,", + "quantileWeighted(if(a, double12 ,", + "if(a, double1, 0) quantileWeighted(", + "", + ]) + expect(weightsBesideLiteralZero(q), q).toBe(old(q)); + let s = 8; + const next = () => { + s = (s * 1103515245 + 12345) & 0x7fffffff; + return s; + }; + const tokens = ["quantileWeighted(", "if(", ",", " ", "double1", ";", "a", "double"]; + for (let n = 0; n < 20_000; n++) { + let q = ""; + for (let i = next() % 10; i > 0; i--) q += tokens[next() % tokens.length]; + expect(weightsBesideLiteralZero(q), JSON.stringify(q)).toBe(old(q)); + } + }); + + it("is linear on many quantileWeighted( and if(", () => { + const start = performance.now(); + expect(weightsBesideLiteralZero("quantileWeighted(".repeat(10_000))).toBe(false); + expect(weightsBesideLiteralZero(`quantileWeighted(${"if(".repeat(50_000)}`)).toBe(false); + expect(performance.now() - start).toBeLessThan(1500); + }); +}); diff --git a/packages/analytics/test/helpers/fake-wae.ts b/packages/analytics/test/helpers/fake-wae.ts index 891f7548..ace9d8d0 100644 --- a/packages/analytics/test/helpers/fake-wae.ts +++ b/packages/analytics/test/helpers/fake-wae.ts @@ -136,7 +136,7 @@ export class FakeWae implements WaeSqlBinding { const alias = q.match(/\b(?:blob|double)\d+ AS (\w+)/); if (alias) throw new SqlApiError(`Input was invalid: column \`${alias[1]}\` not found`); } - if (/quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/.test(q)) + if (weightsBesideLiteralZero(q)) throw new SqlApiError( "Input was invalid: Your query could not be executed due to a type error", ); @@ -154,3 +154,30 @@ export class FakeWae implements WaeSqlBinding { }; } } + +const WEIGHT_ARG = /\s*double\d+\s*,/y; + +/** + * Whether `q` has `quantileWeighted(…if(, doubleN, …` before the next `;`: what + * `/quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/` tested, in one pass (that regex rescanned + * from every `quantileWeighted(` and `if(`; CodeQL alert 8). A `quantileWeighted(` with no match + * before its `;` means none of the later ones before that `;` match either. + */ +export function weightsBesideLiteralZero(q: string): boolean { + let comma = -1; + for (let at = q.indexOf("quantileWeighted("); at !== -1; ) { + const semi = q.indexOf(";", at); + const end = semi === -1 ? q.length : semi; + for (let k = q.indexOf("if(", at + 17); k !== -1 && k < end; k = q.indexOf("if(", k + 1)) { + // `[^,]+`: the condition runs to the first comma after `if(`, at least one character. + if (comma < k + 3) comma = q.indexOf(",", k + 3); + if (comma === -1) return false; + if (comma === k + 3) continue; + WEIGHT_ARG.lastIndex = comma + 1; + if (WEIGHT_ARG.test(q)) return true; + } + if (semi === -1) return false; + at = q.indexOf("quantileWeighted(", semi + 1); + } + return false; +} diff --git a/packages/astro-renderer/test/corrections.test.ts b/packages/astro-renderer/test/corrections.test.ts index 01d45206..e2f64169 100644 --- a/packages/astro-renderer/test/corrections.test.ts +++ b/packages/astro-renderer/test/corrections.test.ts @@ -71,7 +71,7 @@ describe("corrections", () => { const html = renderInlineMarkdown( " [x](javascript:alert(1)) ![pixel](https://evil.example/p.gif)\n\n# Not a heading", ); - expect(html).not.toMatch(//); + expect(html).not.toMatch(//i); expect(html).toContain("<script>"); expect(html).toContain("pixel"); }); diff --git a/scripts/i18n-codemod/catalog-entries.ts b/scripts/i18n-codemod/catalog-entries.ts index 8b49ec4d..d40110ed 100644 --- a/scripts/i18n-codemod/catalog-entries.ts +++ b/scripts/i18n-codemod/catalog-entries.ts @@ -17,6 +17,25 @@ export interface CatalogEntry { comment?: string; } +/** + * The body of a string literal (either quote) as the body of a JSON string: `\'` becomes `'`, a bare `"` + * is escaped, every other escape (`\\`, `\"`, `\n`, `\u…`) stays as written. One pass, so an + * escaped backslash before a quote is never taken apart (the replace chain it replaced turned + * `'a\\"b'`'s `\"` into `\\"`, which ends the JSON string early). + */ +export function stringBodyAsJson(body: string): string { + let out = ""; + for (let k = 0; k < body.length; k++) { + const c = body[k] as string; + if (c === "\\") { + const next = body[k + 1] ?? ""; + out += next === "'" ? "'" : `\\${next}`; + k++; + } else out += c === '"' ? '\\"' : c; + } + return out; +} + class Scanner { i = 0; readonly src: string; @@ -53,9 +72,8 @@ class Scanner { while (this.src[j] !== quote) j += this.src[j] === "\\" ? 2 : 1; const body = this.src.slice(this.i + 1, j); this.i = j + 1; - return quote === '"' - ? (JSON.parse(`"${body}"`) as string) - : JSON.parse(`"${body.replace(/\\'/g, "'").replace(/"/g, '\\"')}"`); + // Either quote: `\'` is legal in a double-quoted literal too, and not in JSON. + return JSON.parse(`"${stringBodyAsJson(body)}"`) as string; } key(): string { diff --git a/scripts/mcp-tools-doc.ts b/scripts/mcp-tools-doc.ts index f0856942..c48b34d7 100644 --- a/scripts/mcp-tools-doc.ts +++ b/scripts/mcp-tools-doc.ts @@ -34,7 +34,9 @@ function summary(description: string): string { // A cut inside a code span would leave it open for the rest of the table. if ((short.match(/`/g) ?? []).length % 2 === 1) short = `${short.slice(0, -1)}\`…`; // `<` outside code spans would read as an HTML tag; inside them it shows as written. + // Backslashes first, so an escaped pipe in a description cannot end the cell. return short + .replace(/\\/g, "\\\\") .replace(/\|/g, "\\|") .split("`") .map((part, i) => (i % 2 === 0 ? part.replace(/ { - const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${version}`); + const res = await fetch(`https://registry.npmjs.org/${name.replaceAll("/", "%2F")}/${version}`); return res.ok; } From 24493b01ab2c579ee6bae37371d002c3729eb506 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:48:48 +0300 Subject: [PATCH 2/2] fix(security): review round for the CodeQL test/script alerts - Move the codemod's string-literal test from apps/studio (its tsconfig rootDir is apps/, so importing scripts/ failed typecheck with TS6059) to packages/cli/test, where the other scripts' tests live. - weightsBesideLiteralZero probes each comma once, however many `if(` share it, and scans the `doubleN,` argument by hand instead of a sticky regex: it was still quadratic on one comma followed by a long whitespace run (CodeQL alert 51 on the PR). The linearity test now covers that input. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .../analytics/test/fake-wae-check.test.ts | 4 +++ packages/analytics/test/helpers/fake-wae.ts | 27 +++++++++++++++---- .../cli/test/i18n-catalog-entries.test.ts | 6 ++++- 3 files changed, 31 insertions(+), 6 deletions(-) rename apps/studio/test/catalog-entries.test.ts => packages/cli/test/i18n-catalog-entries.test.ts (75%) diff --git a/packages/analytics/test/fake-wae-check.test.ts b/packages/analytics/test/fake-wae-check.test.ts index 29d704d0..526cfe10 100644 --- a/packages/analytics/test/fake-wae-check.test.ts +++ b/packages/analytics/test/fake-wae-check.test.ts @@ -33,6 +33,10 @@ describe("weightsBesideLiteralZero", () => { const start = performance.now(); expect(weightsBesideLiteralZero("quantileWeighted(".repeat(10_000))).toBe(false); expect(weightsBesideLiteralZero(`quantileWeighted(${"if(".repeat(50_000)}`)).toBe(false); + // every `if(` shares one comma followed by a long run the probe has to scan + const shared = `quantileWeighted(${"if(".repeat(50_000)}x,${" ".repeat(50_000)}y`; + expect(weightsBesideLiteralZero(shared)).toBe(false); + expect(weightsBesideLiteralZero(`${shared.slice(0, -1)}double1,`)).toBe(true); expect(performance.now() - start).toBeLessThan(1500); }); }); diff --git a/packages/analytics/test/helpers/fake-wae.ts b/packages/analytics/test/helpers/fake-wae.ts index ace9d8d0..715f44e7 100644 --- a/packages/analytics/test/helpers/fake-wae.ts +++ b/packages/analytics/test/helpers/fake-wae.ts @@ -155,16 +155,33 @@ export class FakeWae implements WaeSqlBinding { } } -const WEIGHT_ARG = /\s*double\d+\s*,/y; +const isSpace = (c: string | undefined) => c !== undefined && /\s/.test(c); +const isDigit = (c: string | undefined) => c !== undefined && c >= "0" && c <= "9"; + +/** Whether `\s*double\d+\s*,` matches at `at`, scanned by hand (no regex to rescan). */ +function weightArgAt(q: string, at: number): boolean { + let i = at; + while (isSpace(q[i])) i++; + if (!q.startsWith("double", i)) return false; + i += 6; + const digits = i; + while (isDigit(q[i])) i++; + if (i === digits) return false; + while (isSpace(q[i])) i++; + return q[i] === ","; +} /** * Whether `q` has `quantileWeighted(…if(, doubleN, …` before the next `;`: what * `/quantileWeighted\([^;]*?if\([^,]+,\s*double\d+\s*,/` tested, in one pass (that regex rescanned * from every `quantileWeighted(` and `if(`; CodeQL alert 8). A `quantileWeighted(` with no match - * before its `;` means none of the later ones before that `;` match either. + * before its `;` means none of the later ones before that `;` match either, and each comma is + * probed once however many `if(` share it. */ export function weightsBesideLiteralZero(q: string): boolean { let comma = -1; + // The last comma probed: a probe that matched returned at once, so a repeat can only fail. + let probed = -1; for (let at = q.indexOf("quantileWeighted("); at !== -1; ) { const semi = q.indexOf(";", at); const end = semi === -1 ? q.length : semi; @@ -172,9 +189,9 @@ export function weightsBesideLiteralZero(q: string): boolean { // `[^,]+`: the condition runs to the first comma after `if(`, at least one character. if (comma < k + 3) comma = q.indexOf(",", k + 3); if (comma === -1) return false; - if (comma === k + 3) continue; - WEIGHT_ARG.lastIndex = comma + 1; - if (WEIGHT_ARG.test(q)) return true; + if (comma === k + 3 || comma === probed) continue; + probed = comma; + if (weightArgAt(q, comma + 1)) return true; } if (semi === -1) return false; at = q.indexOf("quantileWeighted(", semi + 1); diff --git a/apps/studio/test/catalog-entries.test.ts b/packages/cli/test/i18n-catalog-entries.test.ts similarity index 75% rename from apps/studio/test/catalog-entries.test.ts rename to packages/cli/test/i18n-catalog-entries.test.ts index 10b019dc..cf85bdc9 100644 --- a/apps/studio/test/catalog-entries.test.ts +++ b/packages/cli/test/i18n-catalog-entries.test.ts @@ -1,4 +1,8 @@ -/** Alert 37: the i18n codemod decodes string literals of either quote without breaking escapes. */ +/** + * Alert 37: the i18n codemod (scripts/i18n-codemod, tested here with the other scripts, whose + * files this package's tsconfig covers) decodes string literals of either quote without breaking + * escapes. + */ import { describe, expect, it } from "vitest"; import { stringBodyAsJson } from "../../../scripts/i18n-codemod/catalog-entries.ts";