From 216e9e0379058de2e7b1fb1b5ceff51aa1dc3fc2 Mon Sep 17 00:00:00 2001 From: leemour Date: Sat, 10 Oct 2026 16:40:55 +0200 Subject: [PATCH] fix: retain runtime coverage for Semgrep TypeScript parse failures --- CHANGELOG.md | 2 + docs/security/METHOD.md | 14 ++- package.json | 3 +- scripts/security-semgrep.test.ts | 87 +++++++++++++++++ scripts/security/scan | 11 +-- scripts/security/semgrep.ts | 154 +++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 scripts/security-semgrep.test.ts create mode 100644 scripts/security/semgrep.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b15a81f..0ced303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Every entry says what changed as a caller sees it, why, and what to watch for ### Fixed +- When pinned Semgrep cannot parse a complete TypeScript file, scan its runtime JavaScript with pinned esbuild, retaining original errors, source maps and explicit type-syntax coverage limits. Compilation or missing runtime coverage fails the run. +- The scanner helper requires Node 22.16 or newer. - **Agent fixtures support private dialogs with different addresses and message IDs per account.** Set the sender's dialog and enable per-account IDs in the private cast; each case needs a unique synthetic label to prove receipt and cleanup on the owner's side. diff --git a/docs/security/METHOD.md b/docs/security/METHOD.md index cc75bd1..2c93edd 100644 --- a/docs/security/METHOD.md +++ b/docs/security/METHOD.md @@ -27,10 +27,11 @@ hosted scanner. tool can produce it, so two runs can be diffed. 5. Do the manual review (section "Checklist"). Every item gets a verdict in `runs//REPORT.md`: safe, finding, or not applicable — each with the evidence that supports it. -6. Before a candidate finding gets an id, run the Trail of Bits `fp-check` skill on it, through its - helper agents (its deep route requires them). Keep the plugin disabled between audits: its Stop - hook fires on every session in every project ([`LESSONS.md`](LESSONS.md)). A confirmed finding is - journaled, then added to the private register. +6. Before a candidate finding gets an id, verify it against the current source and a bounded + synthetic regression. Record the evidence and limitations in the private report. Run scanners + separately; keep plugin hooks disabled during continuation work so a Stop hook cannot interrupt + the session ([`LESSONS.md`](LESSONS.md)). A confirmed finding is journaled, then added to the + private register. 7. Compare with the previous run: new findings, findings fixed, findings that came back. The comparison is the last section of `REPORT.md`. @@ -59,6 +60,11 @@ Command-line scanners, each pinned to a version recorded in `SCOPE.md`: - `semgrep` CE, pinned to a release at least two weeks old (1.178.0 on 2026-10-09; run 1 used 1.180.0, which was too new) — rule packs `p/javascript`, `p/typescript`, `p/nodejs`, `p/secrets`, `p/github-actions`. + When its pinned TypeScript parser fails on a whole file, the runner scans that file’s runtime + JavaScript compiled with `esbuild@0.25.12` targeting ES2019. It maps findings back to source and + keeps the original report, emitted code and source maps under `supplement/`. Compilation, runtime + parsing or missing coverage fails the run. Type-only syntax remains outside this fallback; + partial parsing warnings remain visible. - `zizmor` — GitHub Actions workflows. - CodeQL — through GitHub default setup; alerts read with `gh api`. diff --git a/package.json b/package.json index 9ace022..964111e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "type": "module", "engines": { - "node": ">=22" + "node": "^22.16.0 || >=24" }, "packageManager": "pnpm@11.20.0", "files": [ @@ -18,6 +18,7 @@ "README.md", "LICENSE", "scripts/security/scan", + "scripts/security/semgrep.ts", "scripts/security/socket", "scripts/agent/run-agent", "scripts/agent/run-payload", diff --git a/scripts/security-semgrep.test.ts b/scripts/security-semgrep.test.ts new file mode 100644 index 0000000..8d650b9 --- /dev/null +++ b/scripts/security-semgrep.test.ts @@ -0,0 +1,87 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { scanSemgrep } from "./security/semgrep.ts" + +const fixture = () => { + const directory = mkdtempSync(join(tmpdir(), "scanner-runtime-")) + const root = join(directory, "source") + mkdirSync(join(root, "src"), { recursive: true }) + writeFileSync(join(root, "src/bad.ts"), "const value = request.input\nexec(value)\n") + return { root, output: join(directory, "raw/semgrep.json"), supplement: join(directory, "supplement") } +} +const result = (errors: unknown[] = [], results: unknown[] = [], scanned = ["src/bad.ts"]) => + JSON.stringify({ results, errors, paths: { scanned } }) +const error = { type: "Syntax error", path: "src/bad.ts" } + +describe("Semgrep runtime fallback", () => { + it("retains source failures and maps a positive runtime control to its original source", () => { + const { root, output, supplement } = fixture() + let scans = 0 + const done = scanSemgrep(root, output, supplement, (command, args) => { + if (command === "pnpm") { + expect(args).toContain("esbuild@0.25.12") + expect(args).toContain("./src/bad.ts") + const runtime = join(supplement, "runtime/src") + mkdirSync(runtime, { recursive: true }) + writeFileSync(join(runtime, "bad.js"), "exec(value);\n") + writeFileSync( + join(runtime, "bad.js.map"), + JSON.stringify({ + version: 3, + sources: ["bad.ts"], + names: [], + mappings: "AACA", + sourcesContent: ["const value = request.input\nexec(value)\n"], + }), + ) + return { code: 0, stdout: "" } + } + return { + code: 0, + stdout: + ++scans === 1 + ? result([error]) + : result( + [], + [ + { + check_id: "synthetic.unsafe-exec", + path: "src/bad.js", + start: { line: 1, col: 1 }, + end: { line: 1, col: 5 }, + }, + ], + ["src/bad.js"], + ), + } + }) + expect(done.errors).toEqual([]) + expect(done.results[0]).toMatchObject({ path: "src/bad.ts", start: { line: 2, col: 1 } }) + expect(JSON.parse(readFileSync(join(supplement, "source.json"), "utf8")).errors).toEqual([error]) + expect(done.runtimeCoverage).toMatchObject({ sources: ["src/bad.ts"], typeSyntaxAnalyzed: false }) + }) + + it.each(["compile", "parse", "missing"])("does not write a successful result after a %s failure", (failure) => { + const { root, output, supplement } = fixture() + let scans = 0 + expect(() => + scanSemgrep(root, output, supplement, (command) => { + if (command === "pnpm") return { code: failure === "compile" ? 1 : 0, stdout: "" } + return { code: 0, stdout: ++scans === 1 ? result([error]) : result(failure === "parse" ? [error] : [], [], []) } + }), + ).toThrow() + expect(existsSync(output)).toBe(false) + }) + + it("rejects a scanner error path outside the export before starting a compiler", () => { + const { root, output, supplement } = fixture() + expect(() => + scanSemgrep(root, output, supplement, (command) => { + expect(command).toBe("semgrep") + return { code: 0, stdout: result([{ type: "Syntax error", path: "../outside.ts" }]) } + }), + ).toThrow("outside the source export") + }) +}) diff --git a/scripts/security/scan b/scripts/security/scan index 9e4b748..01e7bb6 100755 --- a/scripts/security/scan +++ b/scripts/security/scan @@ -4,6 +4,7 @@ # scripts/security/scan ~/Projects/AI/cli-private/security/runs/2026-11-01 ~/Projects/AI/max-cli ~/Projects/AI/tg-cli # Writes /SCOPE.md and /raw//{osv,semgrep,zizmor,codeql}.json. set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" run="$1"; shift [ "$#" -gt 0 ] || { echo "usage: scan ..." >&2; exit 2; } @@ -40,6 +41,7 @@ done echo "- osv-scanner $(osv-scanner --version | head -1 | awk '{print $3}')" echo "- semgrep $(semgrep --version)" echo "- $(zizmor --version)" + echo "- esbuild 0.25.12 (runtime parse fallback only)" echo echo "## GitHub settings" echo @@ -74,13 +76,8 @@ for checkout in "$@"; do if [ -f pnpm-lock.yaml ]; then run_tool osv-scanner "0 1" "$out/osv.json" osv-scanner scan source -r --format json . fi - run_tool semgrep "0" "$out/semgrep.json" semgrep scan --metrics=off --quiet --json \ - --config p/javascript --config p/typescript --config p/nodejs --config p/secrets \ - --config p/github-actions . - if jq -e '[.errors[] | select(.type[0]? != "PartialParsing")] | length > 0' "$out/semgrep.json" > /dev/null; then - echo "$name: semgrep reported errors other than PartialParsing" >&2 - exit 1 - fi + run_tool semgrep "0" /dev/null node --experimental-strip-types "$here/semgrep.ts" \ + "$work/$name" "$out/semgrep.json" "$run/supplement/$name" if [ -d .github ]; then run_tool zizmor "0 11 12 13 14" "$out/zizmor.json" zizmor --offline --format json .github fi diff --git a/scripts/security/semgrep.ts b/scripts/security/semgrep.ts new file mode 100644 index 0000000..4740a57 --- /dev/null +++ b/scripts/security/semgrep.ts @@ -0,0 +1,154 @@ +import { spawnSync } from "node:child_process" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { SourceMap } from "node:module" +import { dirname, extname, isAbsolute, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const PACKS = ["p/javascript", "p/typescript", "p/nodejs", "p/secrets", "p/github-actions"] +const ESBUILD = "0.25.12" +type Failure = { type: string | unknown[]; path?: string } +type Match = { + check_id: string + path: string + start: { line: number; col: number } + end: { line: number; col: number } +} +type Result = { results: Match[]; errors: Failure[]; paths: { scanned: string[] }; [key: string]: unknown } +type Run = (command: string, args: string[], cwd: string) => { code: number; stdout: string } +const run: Run = (command, args, cwd) => { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + timeout: 600_000, + stdio: ["ignore", "pipe", "inherit"], + }) + return { code: result.status ?? 2, stdout: result.stdout ?? "" } +} +const partial = (error: Failure) => Array.isArray(error.type) && error.type[0] === "PartialParsing" +const within = (path: string, root: string) => { + const candidate = relative(root, resolve(root, path)) + if (!candidate || candidate.startsWith("..") || isAbsolute(candidate)) + throw new Error("scanner path is outside the source export") + return candidate +} + +export const scanSemgrep = (root: string, output: string, supplement: string, invoke: Run = run): Result => { + const args = [ + "scan", + "--metrics=off", + "--quiet", + "--json", + "--timeout", + "120", + "--timeout-threshold", + "0", + "--jobs", + "2", + ...PACKS.flatMap((pack) => ["--config", pack]), + ] + const scan = (directory: string, targets = ["."]): Result => { + const result = invoke("semgrep", [...args, ...targets], directory) + if (result.code !== 0) throw new Error(`semgrep failed with exit ${result.code}`) + const value = JSON.parse(result.stdout) as Result + if (!Array.isArray(value.results) || !Array.isArray(value.errors) || !Array.isArray(value.paths?.scanned)) + throw new Error("invalid Semgrep result") + return value + } + const source = scan(root) + const failures = source.errors.filter((error) => !partial(error)) + mkdirSync(supplement, { recursive: true, mode: 0o700 }) + writeFileSync(resolve(supplement, "source.json"), `${JSON.stringify(source)}\n`, { mode: 0o600 }) + if (failures.length) { + if ( + failures.some( + (error) => error.type !== "Syntax error" || !error.path || ![".ts", ".tsx"].includes(extname(error.path)), + ) + ) + throw new Error("Semgrep reported an unrecoverable error") + const files = [...new Set(failures.map((error) => within(error.path as string, root)))] + const compiled = resolve(supplement, "runtime") + // ES2019 lowers the syntax rejected by the pinned parser; imports are never bundled or executed. + const build = invoke( + "pnpm", + [ + "dlx", + `esbuild@${ESBUILD}`, + ...files.map((file) => `./${file}`), + `--outdir=${compiled}`, + "--outbase=.", + "--platform=node", + "--target=es2019", + "--format=esm", + "--charset=ascii", + "--sourcemap=external", + "--sources-content=true", + "--log-level=error", + ], + root, + ) + if (build.code !== 0) throw new Error(`runtime coverage compilation failed with exit ${build.code}`) + const expected = files.map((file) => file.replace(/\.(?:tsx|ts)$/, ".js")) + const fallback = scan( + compiled, + expected.map((file) => `./${file}`), + ) + writeFileSync(resolve(supplement, "runtime.json"), `${JSON.stringify(fallback)}\n`, { mode: 0o600 }) + if (fallback.errors.length) throw new Error("Semgrep runtime fallback contains parsing or analysis errors") + const scanned = new Set(fallback.paths.scanned.map((file) => within(file, compiled))) + if (expected.some((file) => !scanned.has(file))) + throw new Error("runtime fallback did not scan every failed source") + const original = new Map(expected.map((file, index) => [file, files[index] as string])) + for (const match of fallback.results) { + const file = within(match.path, compiled), + sourcePath = original.get(file) + if (!sourcePath) throw new Error("runtime match has no original source") + const map = new SourceMap(JSON.parse(readFileSync(resolve(compiled, `${file}.map`), "utf8"))) + const mapped = (point: { line: number; col: number }) => { + const entry = map.findEntry(point.line - 1, point.col - 1) + if ( + !("originalLine" in entry) || + !("originalColumn" in entry) || + typeof entry.originalLine !== "number" || + typeof entry.originalColumn !== "number" + ) + throw new Error("runtime match cannot be mapped to original source") + return { ...point, line: entry.originalLine + 1, col: entry.originalColumn + 1 } + } + source.results.push({ ...match, path: sourcePath, start: mapped(match.start), end: mapped(match.end) }) + } + source.errors = source.errors.filter(partial) + source.runtimeCoverage = { + compiler: `esbuild ${ESBUILD}`, + target: "es2019", + sources: files, + typeSyntaxAnalyzed: false, + originalErrors: relative(dirname(output), resolve(supplement, "source.json")), + runtimeResults: relative(dirname(output), resolve(supplement, "runtime.json")), + } + } + source.results = [ + ...new Map( + source.results.map((match) => [ + JSON.stringify([match.check_id, match.path, match.start.line, match.start.col]), + match, + ]), + ).values(), + ] + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(output, `${JSON.stringify(source)}\n`, { mode: 0o600 }) + return source +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + scanSemgrep( + resolve(process.argv[2] as string), + resolve(process.argv[3] as string), + resolve(process.argv[4] as string), + ) + } catch (error) { + console.error(error instanceof Error ? error.message : "Semgrep coverage failed") + process.exitCode = 1 + } +}