diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 3fb473480..2068f2be3 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -56,6 +56,21 @@ and test name, then set `CODEX_SECURITY_PROPERTY_SEED` and `CODEX_SECURITY_PROPERTY_RUNS` to increase the case count. Pure properties default to 100 cases; filesystem contract properties default to 20. +### Python-to-TypeScript differential checks + +While a bundled helper is being migrated, keep the Python implementation as +the executable oracle and run both implementations against the same fixtures: + +```sh +pnpm run test:normalizer-differential +CODEX_SECURITY_PROPERTY_RUNS=1000 bun test --timeout 900000 tests-ts/normalize-candidates.property.test.ts +``` + +The tests compare output bytes, rejected inputs, filesystem effects, and +ordering invariants. Run them on Linux, macOS, and Windows before changing +the production entrypoint. Subprocess-heavy properties default to eight cases +to stay within the standard test timeout. + ## GitHub Actions `node-ci` retains the required `ubuntu-latest / node-22`, diff --git a/sdk/typescript/_bundled_plugin/scripts/normalize_candidates.mjs b/sdk/typescript/_bundled_plugin/scripts/normalize_candidates.mjs new file mode 100644 index 000000000..5fda010ac --- /dev/null +++ b/sdk/typescript/_bundled_plugin/scripts/normalize_candidates.mjs @@ -0,0 +1,685 @@ +#!/usr/bin/env node +// Generated from plugin-helpers-src/normalize-candidates.ts. +// Do not edit this file directly. + +import { createHash, randomBytes } from "node:crypto"; +import { + closeSync, + createReadStream, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { + dirname, + isAbsolute, + join, + parse, + relative, + resolve, + sep, +} from "node:path"; +import { pathToFileURL } from "node:url"; +const CWE = /^CWE-(\d+)$/iu; +const ROLES = new Map([ + ["entrypoint", 0], + ["entrypoint/wrapper", 1], + ["source", 2], + ["root_control", 3], + ["sink", 4], + ["concrete_implementation", 5], + ["evidence", 6], +]); +const FIELDS = new Set([ + "candidate_id", + "cwe_ids", + "locations", + "summary", + "evidence", + "context", + "instance", +]); +const LOCATION_FIELDS = new Set(["path", "start_line", "end_line", "role"]); +const PYTHON_WHITESPACE_START = + /^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/u; +const PYTHON_WHITESPACE_END = + /[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/u; +const LONG_OPTIONS = [ + "--input", + "--out", + "--repo-root", + "--in-scope-files", + "--allow-missing-in-scope", +]; +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function pythonStrip(value) { + return value + .replace(PYTHON_WHITESPACE_START, "") + .replace(PYTHON_WHITESPACE_END, ""); +} +function comparePythonStrings(left, right) { + let leftIndex = 0; + let rightIndex = 0; + while (leftIndex < left.length && rightIndex < right.length) { + const leftPoint = left.codePointAt(leftIndex); + const rightPoint = right.codePointAt(rightIndex); + if (leftPoint !== rightPoint) return leftPoint - rightPoint; + leftIndex += leftPoint > 0xffff ? 2 : 1; + rightIndex += rightPoint > 0xffff ? 2 : 1; + } + return left.length - right.length; +} +function canonicalValue(value) { + if ( + value === null || + typeof value === "boolean" || + typeof value === "number" + ) { + return value; + } + if (typeof value === "string") { + if (!value.isWellFormed()) { + throw new Error("expected valid Unicode text"); + } + return value; + } + if (Array.isArray(value)) return value.map(canonicalValue); + if (isObject(value)) { + const result = {}; + for (const key of Object.keys(value).sort(comparePythonStrings)) { + result[key] = canonicalValue(value[key]); + } + return result; + } + throw new Error("expected a JSON value"); +} +function canonicalJson(value) { + return JSON.stringify(canonicalValue(value)); +} +function textField(row, field, required = true) { + const value = row[field]; + if ((value === null || value === undefined) && !required) return undefined; + if (typeof value !== "string" || !pythonStrip(value)) { + throw new Error(`${field}: expected a non-empty string`); + } + return pythonStrip(value); +} +function cweIds(row) { + const value = row["cwe_ids"]; + if (!Array.isArray(value)) throw new Error("cwe_ids: expected an array"); + const found = new Set(); + for (const item of value) { + if (typeof item !== "string") { + throw new Error("cwe_ids: expected CWE strings"); + } + const match = CWE.exec(pythonStrip(item)); + const number = match?.[1] === undefined ? 0n : BigInt(match[1]); + if (match === null || number < 1n) { + throw new Error(`cwe_ids: unsupported value ${JSON.stringify(item)}`); + } + found.add(number.toString()); + } + return [...found] + .sort((left, right) => { + const leftNumber = BigInt(left); + const rightNumber = BigInt(right); + return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0; + }) + .map((number) => `CWE-${number}`); +} +function errorCode(error) { + return error instanceof Error ? error.code : undefined; +} +function relativeInside(root, candidate) { + const result = relative(root, candidate); + if (result === ".." || result.startsWith(`..${sep}`) || isAbsolute(result)) { + return undefined; + } + return result.split(sep).join("/"); +} +function posixParts(value) { + return value.split("/").filter((part) => part !== "" && part !== "."); +} +export function relativeFile(value, repoRoot) { + if (typeof value !== "string" || !value || value.includes("\0")) { + throw new Error("path: expected a non-empty repository-relative path"); + } + const raw = + process.platform === "win32" ? value.replaceAll("\\", "/") : value; + const parts = posixParts(raw); + if ( + raw.startsWith("/") || + parts.includes("..") || + (process.platform === "win32" && /^[A-Za-z]:/u.test(raw)) + ) { + throw new Error( + "path: expected a repository-relative path without traversal", + ); + } + const source = realpathSync(resolve(repoRoot, ...parts)); + const relativePath = relativeInside(repoRoot, source); + if (relativePath === undefined) { + throw new Error("path: must resolve inside --repo-root"); + } + if (!statSync(source).isFile()) { + throw new Error("path: expected a regular file"); + } + return [relativePath, source]; +} +function positiveLine(value, field) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new Error(`${field}: expected a positive integer`); + } + return value; +} +function countLines(source) { + const contents = readFileSync(source); + if (contents.length === 0) return 0; + let lines = 0; + for (let index = 0; index < contents.length; index += 1) { + const byte = contents[index]; + if (byte === 0x0d) { + lines += 1; + if (contents[index + 1] === 0x0a) index += 1; + } else if (byte === 0x0a) { + lines += 1; + } + } + const last = contents[contents.length - 1]; + return last === 0x0a || last === 0x0d ? lines : lines + 1; +} +function normalizeLocations(row, repoRoot, lineCounts) { + const value = row["locations"]; + if (!Array.isArray(value) || value.length === 0) { + throw new Error("locations: expected a non-empty array"); + } + const normalized = new Map(); + for (const item of value) { + if (!isObject(item)) { + throw new Error("locations: expected location objects"); + } + const unknown = Object.keys(item) + .filter((field) => !LOCATION_FIELDS.has(field)) + .sort(comparePythonStrings); + if (unknown.length > 0) { + throw new Error(`locations: unsupported fields ${unknown.join(", ")}`); + } + const [relativePath, source] = relativeFile(item["path"], repoRoot); + if ( + !pythonStrip(relativePath) || + relativePath.includes("\\") || + relativePath.split("/").some((part) => part.includes(":")) + ) { + throw new Error("path: expected a safe repository-relative POSIX path"); + } + const start = positiveLine(item["start_line"], "start_line"); + const end = positiveLine( + Object.hasOwn(item, "end_line") ? item["end_line"] : start, + "end_line", + ); + if (end < start) { + throw new Error("end_line: must be greater than or equal to start_line"); + } + let lineCount = lineCounts.get(source); + if (lineCount === undefined) { + lineCount = countLines(source); + lineCounts.set(source, lineCount); + } + if (end > lineCount) { + throw new Error( + `line range ${start}-${end} exceeds ${relativePath}:${lineCount}`, + ); + } + const role = item["role"]; + if (typeof role !== "string" || !ROLES.has(role)) { + throw new Error(`role: unsupported value ${JSON.stringify(role)}`); + } + const location = { + path: relativePath, + start_line: start, + end_line: end, + role, + }; + normalized.set(canonicalJson(location), location); + } + return [...normalized.values()].sort((left, right) => { + const role = ROLES.get(left.role) - ROLES.get(right.role); + if (role !== 0) return role; + const path = comparePythonStrings(left.path, right.path); + if (path !== 0) return path; + return left.start_line - right.start_line || left.end_line - right.end_line; + }); +} +function decodeUtf8(contents) { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + contents, + ); +} +function resolveAllowMissing(value) { + const absolute = isAbsolute(value) + ? value + : `${process.cwd()}${process.cwd().endsWith(sep) ? "" : sep}${value}`; + const splitPath = (path) => { + const root = parse(path).root; + const remainder = path.slice(root.length); + return { + root, + parts: + process.platform === "win32" + ? remainder.split(/[\\/]/u) + : remainder.split("/"), + }; + }; + const initialPath = splitPath(absolute); + let parts = initialPath.parts; + let current = initialPath.root; + let index = 0; + const seenStates = new Set(); + while (index < parts.length) { + const component = parts[index++]; + if (!component || component === ".") continue; + if (component === "..") { + current = dirname(current); + continue; + } + const candidate = join(current, component); + let entry; + try { + entry = lstatSync(candidate); + } catch (error) { + if (errorCode(error) === "ENOENT") { + current = candidate; + continue; + } + throw error; + } + if (!entry.isSymbolicLink()) { + current = candidate; + continue; + } + const target = readlinkSync(candidate); + const remainder = parts.slice(index); + const targetPath = splitPath(target); + const state = canonicalJson([candidate, target, remainder]); + if (seenStates.has(state)) { + throw new Error(`too many symbolic links while resolving ${value}`); + } + seenStates.add(state); + current = isAbsolute(target) ? targetPath.root : dirname(candidate); + parts = [...targetPath.parts, ...remainder]; + index = 0; + } + return current; +} +export function readScope(scopePath, repoRoot, allowMissing = false) { + const contents = decodeUtf8(readFileSync(scopePath)); + const lines = contents.split("\n"); + const listedRows = new Set(lines); + const isScopeFile = (value) => { + try { + relativeFile(value, repoRoot); + return true; + } catch { + return false; + } + }; + const carriageRows = new Map(); + if (process.platform !== "win32") { + for (const line of lines) { + if (line.endsWith("\r") && line !== "\r") { + carriageRows.set(line, [ + isScopeFile(line), + isScopeFile(line.slice(0, -1)), + ]); + } + } + } + const crlfEvidence = + lines.some((line) => line === "\r") || + [...carriageRows.values()].some( + ([literal, stripped]) => stripped && !literal, + ); + const literalEvidence = [...carriageRows.values()].some( + ([literal, stripped]) => literal && !stripped, + ); + const scope = new Set(); + for (const [index, originalLine] of lines.entries()) { + const number = index + 1; + let line = originalLine; + if (process.platform === "win32" || line === "\r") { + if (line.endsWith("\r")) line = line.slice(0, -1); + } else if (line.endsWith("\r")) { + const [literal, stripped] = carriageRows.get(line); + if (stripped && !literal) { + line = line.slice(0, -1); + } else if (stripped && literal) { + if (number === lines.length && !contents.endsWith("\n")) { + // A final unterminated carriage return is part of the path. + } else if (listedRows.has(line.slice(0, -1))) { + // The stripped spelling is listed separately, so this row is literal. + } else if (crlfEvidence && !literalEvidence) { + line = line.slice(0, -1); + } else if (!literalEvidence || crlfEvidence) { + throw new Error( + `in-scope file row ${number}: ambiguous carriage-return paths`, + ); + } + } else if (!literal && crlfEvidence) { + line = line.slice(0, -1); + } + } + if (!line) continue; + try { + const [relativePath] = relativeFile(line, repoRoot); + scope.add(relativePath); + } catch (error) { + if (allowMissing && errorCode(error) === "ENOENT") { + const parts = posixParts(line); + if ( + line.startsWith("/") || + parts.includes("..") || + line.includes("\0") + ) { + throw new Error(`in-scope file row ${number}: unsafe deleted path`); + } + const resolved = resolveAllowMissing(resolve(repoRoot, line)); + const relativePath = relativeInside(repoRoot, resolved); + if (relativePath === undefined) { + throw new Error( + `in-scope file row ${number}: path escapes repository`, + ); + } + scope.add(relativePath); + continue; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`in-scope file row ${number}: ${message}`); + } + } + return scope; +} +export function normalizeCandidate(row, repoRoot, scope, lineCounts) { + const unknown = Object.keys(row) + .filter((field) => !FIELDS.has(field)) + .sort(comparePythonStrings); + if (unknown.length > 0) { + throw new Error(`unsupported fields ${unknown.join(", ")}`); + } + if (Object.hasOwn(row, "candidate_id")) textField(row, "candidate_id"); + const locations = normalizeLocations(row, repoRoot, lineCounts); + if (!locations.some((location) => scope.has(location.path))) { + throw new Error("locations: expected at least one in-scope file"); + } + const result = { + cwe_ids: cweIds(row), + locations, + summary: textField(row, "summary"), + evidence: textField(row, "evidence"), + }; + const context = textField(row, "context", false); + if (context !== undefined) result.context = context; + const instance = textField(row, "instance", false); + if (instance !== undefined) result.instance = instance; + return result; +} +function identity(row) { + return canonicalJson({ + cwe_ids: row.cwe_ids, + locations: row.locations, + instance: row.instance ?? null, + }); +} +function mergedText(group, field) { + const values = new Set(); + for (const item of group) { + const value = item[field]; + if (value !== undefined) values.add(value); + } + return [...values].sort(comparePythonStrings).join("\n"); +} +export function combine(rows) { + const groups = new Map(); + for (const row of rows) { + const key = identity(row); + const group = groups.get(key); + if (group === undefined) groups.set(key, [row]); + else group.push(row); + } + const combined = []; + for (const [key, group] of [...groups.entries()].sort(([left], [right]) => + comparePythonStrings(left, right), + )) { + const first = group[0]; + const candidateId = createHash("sha256") + .update(key) + .digest("hex") + .slice(0, 16); + const result = { + candidate_id: `candidate-${candidateId}`, + cwe_ids: first.cwe_ids, + locations: first.locations, + summary: mergedText(group, "summary"), + evidence: mergedText(group, "evidence"), + }; + const context = mergedText(group, "context"); + if (context) result.context = context; + if (first.instance !== undefined) result.instance = first.instance; + combined.push(result); + } + return combined; +} +async function* lines(source) { + const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + let remainder = ""; + let number = 0; + for await (const chunk of createReadStream(source)) { + remainder += decoder.decode(chunk, { stream: true }); + let newline = remainder.indexOf("\n"); + while (newline !== -1) { + number += 1; + yield [number, remainder.slice(0, newline + 1)]; + remainder = remainder.slice(newline + 1); + newline = remainder.indexOf("\n"); + } + } + remainder += decoder.decode(); + if (remainder) yield [number + 1, remainder]; +} +function expandUser(value) { + if (value === "~") return homedir(); + if (value.startsWith(`~${sep}`) || (sep === "\\" && value.startsWith("~/"))) { + return join(homedir(), value.slice(2)); + } + return value; +} +function resolveLongOption(argument) { + if (!argument.startsWith("--")) return { option: argument }; + const equals = argument.indexOf("="); + const spelling = equals === -1 ? argument : argument.slice(0, equals); + const exact = LONG_OPTIONS.find((option) => option === spelling); + const matches = + exact === undefined + ? LONG_OPTIONS.filter((option) => option.startsWith(spelling)) + : [exact]; + if (matches.length === 0) return { option: argument }; + if (matches.length > 1) { + throw new Error(`ambiguous option ${spelling}`); + } + return { + option: matches[0], + ...(equals === -1 ? {} : { attachedValue: argument.slice(equals + 1) }), + }; +} +function isHelpArgument(argument) { + if (argument === "-h" || argument === "--help") return true; + if (!argument.startsWith("--") || argument.includes("=")) return false; + const matches = [...LONG_OPTIONS, "--help"].filter((option) => + option.startsWith(argument), + ); + return matches.length === 1 && matches[0] === "--help"; +} +function parseArguments(argv) { + let inputs; + let output; + let repoRoot; + let scopePath; + let allowMissingInScope = false; + const takeValue = (index, option, attachedValue) => { + if (attachedValue !== undefined) return attachedValue; + const value = argv[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error(`${option}: expected a value`); + } + return value; + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const { attachedValue, option } = resolveLongOption(argument); + if (option === "--input") { + const values = attachedValue === undefined ? [] : [attachedValue]; + while ( + argv[index + 1] !== undefined && + !argv[index + 1].startsWith("-") + ) { + values.push(argv[(index += 1)]); + } + if (values.length === 0) + throw new Error("--input: expected one or more values"); + inputs = values; + } else if (option === "--out") { + output = takeValue(index, option, attachedValue); + if (attachedValue === undefined) index += 1; + } else if (option === "--repo-root") { + repoRoot = takeValue(index, option, attachedValue); + if (attachedValue === undefined) index += 1; + } else if (option === "--in-scope-files") { + scopePath = takeValue(index, option, attachedValue); + if (attachedValue === undefined) index += 1; + } else if (option === "--allow-missing-in-scope") { + if (attachedValue !== undefined) { + throw new Error(`${option}: does not take a value`); + } + allowMissingInScope = true; + } else { + throw new Error(`unrecognized argument ${argument}`); + } + } + if (inputs === undefined) throw new Error("--input is required"); + if (output === undefined) throw new Error("--out is required"); + if (repoRoot === undefined) throw new Error("--repo-root is required"); + if (scopePath === undefined) throw new Error("--in-scope-files is required"); + return { inputs, output, repoRoot, scopePath, allowMissingInScope }; +} +function writeCombined(output, rows) { + mkdirSync(dirname(output), { recursive: true }); + let temporary; + let descriptor; + try { + while (descriptor === undefined) { + temporary = join( + dirname(output), + `.${parse(output).base}.${randomBytes(8).toString("hex")}.tmp`, + ); + try { + descriptor = openSync(temporary, "wx", 0o600); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + } + } + try { + for (const row of rows) { + writeFileSync(descriptor, `${canonicalJson(row)}\n`, { + encoding: "utf8", + }); + } + } finally { + closeSync(descriptor); + descriptor = undefined; + } + renameSync(temporary, output); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + if (temporary !== undefined) { + try { + unlinkSync(temporary); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + } + } +} +async function normalizeCandidates(args) { + const repoRoot = realpathSync(resolve(expandUser(args.repoRoot))); + if (!statSync(repoRoot).isDirectory()) { + throw new Error("--repo-root: expected a directory"); + } + const output = resolveAllowMissing(expandUser(args.output)); + const scopePath = realpathSync(resolve(expandUser(args.scopePath))); + const inputs = [ + ...new Set( + args.inputs.map((value) => realpathSync(resolve(expandUser(value)))), + ), + ].sort(comparePythonStrings); + if (inputs.includes(output)) + throw new Error("--out: must not also be an input"); + if (output === scopePath) { + throw new Error("--out: must not replace --in-scope-files"); + } + const scope = readScope(scopePath, repoRoot, args.allowMissingInScope); + const lineCounts = new Map(); + const rows = []; + for (const source of inputs) { + for await (const [number, line] of lines(source)) { + if (!pythonStrip(line)) continue; + try { + const value = JSON.parse(line); + if (!isObject(value)) throw new Error("expected a JSON object"); + rows.push(normalizeCandidate(value, repoRoot, scope, lineCounts)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${source} row ${number}: ${message}`); + } + } + } + const combined = combine(rows); + writeCombined(output, combined); + return [rows.length, combined.length, output]; +} +const HELP = `Validate and combine security-scan candidates into deterministic JSONL. + +Usage: normalize_candidates.mjs --input [path ...] --out --repo-root --in-scope-files [--allow-missing-in-scope]`; +async function runCli() { + if (process.argv.slice(2).some(isHelpArgument)) { + console.log(HELP); + return; + } + try { + const [rows, combined, output] = await normalizeCandidates( + parseArguments(process.argv.slice(2)), + ); + console.log( + `Combined ${rows} candidate rows into ${combined} rows in ${output}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`normalize_candidates: ${message}`); + process.exitCode = 2; + } +} +const entrypoint = process.argv[1]; +if ( + entrypoint !== undefined && + import.meta.url === pathToFileURL(resolve(entrypoint)).href +) { + await runCli(); +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index b33d037e8..e1fb8e3bf 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -41,7 +41,7 @@ "scripts": { "audit:prod": "pnpm audit --prod --audit-level high", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "build": "node --run clean && tsc -p tsconfig.build.json", + "build": "node scripts/generate-plugin-helpers.mjs && node --run clean && tsc -p tsconfig.build.json", "check:package": "node scripts/check-package.mjs", "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,tsx,json,md}\"", "generate:models": "node scripts/generate-models.cjs", @@ -51,8 +51,9 @@ "test": "bun test --timeout 30000 ./tests-ts", "test:ci": "node -e \"require('node:fs').mkdirSync('reports',{recursive:true})\" && pnpm run test --coverage --coverage-reporter=text --coverage-reporter=lcov --reporter=junit --reporter-outfile=reports/junit.xml", "test:mutation": "stryker run", + "test:normalizer-differential": "bun test --timeout 30000 tests-ts/normalize-candidates.test.ts tests-ts/normalize-candidates-filesystem.test.ts tests-ts/normalize-candidates.property.test.ts", "test:package": "node scripts/smoke-package.mjs", - "types": "pnpm run generate:models:check && tsc --noEmit" + "types": "pnpm run generate:models:check && node scripts/generate-plugin-helpers.mjs --check && tsc --noEmit" }, "dependencies": { "@inquirer/prompts": "8.3.0", diff --git a/sdk/typescript/plugin-files.json b/sdk/typescript/plugin-files.json index 41919526f..b97c4fa0c 100644 --- a/sdk/typescript/plugin-files.json +++ b/sdk/typescript/plugin-files.json @@ -49,6 +49,7 @@ "scripts/generate_rank_input.py", "scripts/launch_codex_security_mcp", "scripts/launch_codex_security_mcp.cmd", + "scripts/normalize_candidates.mjs", "scripts/normalize_candidates.py", "scripts/rank_preview.py", "scripts/report_projection.py", diff --git a/sdk/typescript/plugin-helpers-src/normalize-candidates.ts b/sdk/typescript/plugin-helpers-src/normalize-candidates.ts new file mode 100644 index 000000000..ed6fbff9a --- /dev/null +++ b/sdk/typescript/plugin-helpers-src/normalize-candidates.ts @@ -0,0 +1,787 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + closeSync, + createReadStream, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { + dirname, + isAbsolute, + join, + parse, + relative, + resolve, + sep, +} from "node:path"; +import { pathToFileURL } from "node:url"; + +const CWE = /^CWE-(\d+)$/iu; +const ROLES = new Map([ + ["entrypoint", 0], + ["entrypoint/wrapper", 1], + ["source", 2], + ["root_control", 3], + ["sink", 4], + ["concrete_implementation", 5], + ["evidence", 6], +]); +const FIELDS = new Set([ + "candidate_id", + "cwe_ids", + "locations", + "summary", + "evidence", + "context", + "instance", +]); +const LOCATION_FIELDS = new Set(["path", "start_line", "end_line", "role"]); +const PYTHON_WHITESPACE_START = + /^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/u; +const PYTHON_WHITESPACE_END = + /[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/u; + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; +type JsonObject = Record; + +interface Location { + path: string; + start_line: number; + end_line: number; + role: string; +} + +interface NormalizedCandidate { + cwe_ids: string[]; + locations: Location[]; + summary: string; + evidence: string; + context?: string; + instance?: string; +} + +interface CombinedCandidate extends NormalizedCandidate { + candidate_id: string; +} + +interface CliArguments { + inputs: string[]; + output: string; + repoRoot: string; + scopePath: string; + allowMissingInScope: boolean; +} + +const LONG_OPTIONS = [ + "--input", + "--out", + "--repo-root", + "--in-scope-files", + "--allow-missing-in-scope", +] as const; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pythonStrip(value: string): string { + return value + .replace(PYTHON_WHITESPACE_START, "") + .replace(PYTHON_WHITESPACE_END, ""); +} + +function comparePythonStrings(left: string, right: string): number { + let leftIndex = 0; + let rightIndex = 0; + while (leftIndex < left.length && rightIndex < right.length) { + const leftPoint = left.codePointAt(leftIndex)!; + const rightPoint = right.codePointAt(rightIndex)!; + if (leftPoint !== rightPoint) return leftPoint - rightPoint; + leftIndex += leftPoint > 0xffff ? 2 : 1; + rightIndex += rightPoint > 0xffff ? 2 : 1; + } + return left.length - right.length; +} + +function canonicalValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === "boolean" || + typeof value === "number" + ) { + return value; + } + if (typeof value === "string") { + if (!value.isWellFormed()) { + throw new Error("expected valid Unicode text"); + } + return value; + } + if (Array.isArray(value)) return value.map(canonicalValue); + if (isObject(value)) { + const result: { [key: string]: JsonValue } = {}; + for (const key of Object.keys(value).sort(comparePythonStrings)) { + result[key] = canonicalValue(value[key]); + } + return result; + } + throw new Error("expected a JSON value"); +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalValue(value)); +} + +function textField( + row: JsonObject, + field: string, + required = true, +): string | undefined { + const value = row[field]; + if ((value === null || value === undefined) && !required) return undefined; + if (typeof value !== "string" || !pythonStrip(value)) { + throw new Error(`${field}: expected a non-empty string`); + } + return pythonStrip(value); +} + +function cweIds(row: JsonObject): string[] { + const value = row["cwe_ids"]; + if (!Array.isArray(value)) throw new Error("cwe_ids: expected an array"); + const found = new Set(); + for (const item of value) { + if (typeof item !== "string") { + throw new Error("cwe_ids: expected CWE strings"); + } + const match = CWE.exec(pythonStrip(item)); + const number = match?.[1] === undefined ? 0n : BigInt(match[1]); + if (match === null || number < 1n) { + throw new Error(`cwe_ids: unsupported value ${JSON.stringify(item)}`); + } + found.add(number.toString()); + } + return [...found] + .sort((left, right) => { + const leftNumber = BigInt(left); + const rightNumber = BigInt(right); + return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0; + }) + .map((number) => `CWE-${number}`); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function relativeInside(root: string, candidate: string): string | undefined { + const result = relative(root, candidate); + if (result === ".." || result.startsWith(`..${sep}`) || isAbsolute(result)) { + return undefined; + } + return result.split(sep).join("/"); +} + +function posixParts(value: string): string[] { + return value.split("/").filter((part) => part !== "" && part !== "."); +} + +export function relativeFile( + value: unknown, + repoRoot: string, +): [string, string] { + if (typeof value !== "string" || !value || value.includes("\0")) { + throw new Error("path: expected a non-empty repository-relative path"); + } + const raw = + process.platform === "win32" ? value.replaceAll("\\", "/") : value; + const parts = posixParts(raw); + if ( + raw.startsWith("/") || + parts.includes("..") || + (process.platform === "win32" && /^[A-Za-z]:/u.test(raw)) + ) { + throw new Error( + "path: expected a repository-relative path without traversal", + ); + } + const source = realpathSync(resolve(repoRoot, ...parts)); + const relativePath = relativeInside(repoRoot, source); + if (relativePath === undefined) { + throw new Error("path: must resolve inside --repo-root"); + } + if (!statSync(source).isFile()) { + throw new Error("path: expected a regular file"); + } + return [relativePath, source]; +} + +function positiveLine(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new Error(`${field}: expected a positive integer`); + } + return value; +} + +function countLines(source: string): number { + const contents = readFileSync(source); + if (contents.length === 0) return 0; + let lines = 0; + for (let index = 0; index < contents.length; index += 1) { + const byte = contents[index]; + if (byte === 0x0d) { + lines += 1; + if (contents[index + 1] === 0x0a) index += 1; + } else if (byte === 0x0a) { + lines += 1; + } + } + const last = contents[contents.length - 1]; + return last === 0x0a || last === 0x0d ? lines : lines + 1; +} + +function normalizeLocations( + row: JsonObject, + repoRoot: string, + lineCounts: Map, +): Location[] { + const value = row["locations"]; + if (!Array.isArray(value) || value.length === 0) { + throw new Error("locations: expected a non-empty array"); + } + const normalized = new Map(); + for (const item of value) { + if (!isObject(item)) { + throw new Error("locations: expected location objects"); + } + const unknown = Object.keys(item) + .filter((field) => !LOCATION_FIELDS.has(field)) + .sort(comparePythonStrings); + if (unknown.length > 0) { + throw new Error(`locations: unsupported fields ${unknown.join(", ")}`); + } + const [relativePath, source] = relativeFile(item["path"], repoRoot); + if ( + !pythonStrip(relativePath) || + relativePath.includes("\\") || + relativePath.split("/").some((part) => part.includes(":")) + ) { + throw new Error("path: expected a safe repository-relative POSIX path"); + } + const start = positiveLine(item["start_line"], "start_line"); + const end = positiveLine( + Object.hasOwn(item, "end_line") ? item["end_line"] : start, + "end_line", + ); + if (end < start) { + throw new Error("end_line: must be greater than or equal to start_line"); + } + let lineCount = lineCounts.get(source); + if (lineCount === undefined) { + lineCount = countLines(source); + lineCounts.set(source, lineCount); + } + if (end > lineCount) { + throw new Error( + `line range ${start}-${end} exceeds ${relativePath}:${lineCount}`, + ); + } + const role = item["role"]; + if (typeof role !== "string" || !ROLES.has(role)) { + throw new Error(`role: unsupported value ${JSON.stringify(role)}`); + } + const location = { + path: relativePath, + start_line: start, + end_line: end, + role, + }; + normalized.set(canonicalJson(location), location); + } + return [...normalized.values()].sort((left, right) => { + const role = ROLES.get(left.role)! - ROLES.get(right.role)!; + if (role !== 0) return role; + const path = comparePythonStrings(left.path, right.path); + if (path !== 0) return path; + return left.start_line - right.start_line || left.end_line - right.end_line; + }); +} + +function decodeUtf8(contents: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + contents, + ); +} + +function resolveAllowMissing(value: string): string { + const absolute = isAbsolute(value) + ? value + : `${process.cwd()}${process.cwd().endsWith(sep) ? "" : sep}${value}`; + const splitPath = (path: string): { parts: string[]; root: string } => { + const root = parse(path).root; + const remainder = path.slice(root.length); + return { + root, + parts: + process.platform === "win32" + ? remainder.split(/[\\/]/u) + : remainder.split("/"), + }; + }; + const initialPath = splitPath(absolute); + let parts = initialPath.parts; + let current = initialPath.root; + let index = 0; + const seenStates = new Set(); + while (index < parts.length) { + const component = parts[index++]!; + if (!component || component === ".") continue; + if (component === "..") { + current = dirname(current); + continue; + } + const candidate = join(current, component); + let entry; + try { + entry = lstatSync(candidate); + } catch (error) { + if (errorCode(error) === "ENOENT") { + current = candidate; + continue; + } + throw error; + } + if (!entry.isSymbolicLink()) { + current = candidate; + continue; + } + const target = readlinkSync(candidate); + const remainder = parts.slice(index); + const targetPath = splitPath(target); + const state = canonicalJson([candidate, target, remainder]); + if (seenStates.has(state)) { + throw new Error(`too many symbolic links while resolving ${value}`); + } + seenStates.add(state); + current = isAbsolute(target) ? targetPath.root : dirname(candidate); + parts = [...targetPath.parts, ...remainder]; + index = 0; + } + return current; +} + +export function readScope( + scopePath: string, + repoRoot: string, + allowMissing = false, +): Set { + const contents = decodeUtf8(readFileSync(scopePath)); + const lines = contents.split("\n"); + const listedRows = new Set(lines); + const isScopeFile = (value: string): boolean => { + try { + relativeFile(value, repoRoot); + return true; + } catch { + return false; + } + }; + const carriageRows = new Map(); + if (process.platform !== "win32") { + for (const line of lines) { + if (line.endsWith("\r") && line !== "\r") { + carriageRows.set(line, [ + isScopeFile(line), + isScopeFile(line.slice(0, -1)), + ]); + } + } + } + const crlfEvidence = + lines.some((line) => line === "\r") || + [...carriageRows.values()].some( + ([literal, stripped]) => stripped && !literal, + ); + const literalEvidence = [...carriageRows.values()].some( + ([literal, stripped]) => literal && !stripped, + ); + + const scope = new Set(); + for (const [index, originalLine] of lines.entries()) { + const number = index + 1; + let line = originalLine; + if (process.platform === "win32" || line === "\r") { + if (line.endsWith("\r")) line = line.slice(0, -1); + } else if (line.endsWith("\r")) { + const [literal, stripped] = carriageRows.get(line)!; + if (stripped && !literal) { + line = line.slice(0, -1); + } else if (stripped && literal) { + if (number === lines.length && !contents.endsWith("\n")) { + // A final unterminated carriage return is part of the path. + } else if (listedRows.has(line.slice(0, -1))) { + // The stripped spelling is listed separately, so this row is literal. + } else if (crlfEvidence && !literalEvidence) { + line = line.slice(0, -1); + } else if (!literalEvidence || crlfEvidence) { + throw new Error( + `in-scope file row ${number}: ambiguous carriage-return paths`, + ); + } + } else if (!literal && crlfEvidence) { + line = line.slice(0, -1); + } + } + if (!line) continue; + try { + const [relativePath] = relativeFile(line, repoRoot); + scope.add(relativePath); + } catch (error) { + if (allowMissing && errorCode(error) === "ENOENT") { + const parts = posixParts(line); + if ( + line.startsWith("/") || + parts.includes("..") || + line.includes("\0") + ) { + throw new Error(`in-scope file row ${number}: unsafe deleted path`); + } + const resolved = resolveAllowMissing(resolve(repoRoot, line)); + const relativePath = relativeInside(repoRoot, resolved); + if (relativePath === undefined) { + throw new Error( + `in-scope file row ${number}: path escapes repository`, + ); + } + scope.add(relativePath); + continue; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`in-scope file row ${number}: ${message}`); + } + } + return scope; +} + +export function normalizeCandidate( + row: JsonObject, + repoRoot: string, + scope: Set, + lineCounts: Map, +): NormalizedCandidate { + const unknown = Object.keys(row) + .filter((field) => !FIELDS.has(field)) + .sort(comparePythonStrings); + if (unknown.length > 0) { + throw new Error(`unsupported fields ${unknown.join(", ")}`); + } + if (Object.hasOwn(row, "candidate_id")) textField(row, "candidate_id"); + const locations = normalizeLocations(row, repoRoot, lineCounts); + if (!locations.some((location) => scope.has(location.path))) { + throw new Error("locations: expected at least one in-scope file"); + } + const result: NormalizedCandidate = { + cwe_ids: cweIds(row), + locations, + summary: textField(row, "summary")!, + evidence: textField(row, "evidence")!, + }; + const context = textField(row, "context", false); + if (context !== undefined) result.context = context; + const instance = textField(row, "instance", false); + if (instance !== undefined) result.instance = instance; + return result; +} + +function identity(row: NormalizedCandidate): string { + return canonicalJson({ + cwe_ids: row.cwe_ids, + locations: row.locations, + instance: row.instance ?? null, + }); +} + +function mergedText( + group: NormalizedCandidate[], + field: "summary" | "evidence" | "context", +): string { + const values = new Set(); + for (const item of group) { + const value = item[field]; + if (value !== undefined) values.add(value); + } + return [...values].sort(comparePythonStrings).join("\n"); +} + +export function combine(rows: NormalizedCandidate[]): CombinedCandidate[] { + const groups = new Map(); + for (const row of rows) { + const key = identity(row); + const group = groups.get(key); + if (group === undefined) groups.set(key, [row]); + else group.push(row); + } + const combined: CombinedCandidate[] = []; + for (const [key, group] of [...groups.entries()].sort(([left], [right]) => + comparePythonStrings(left, right), + )) { + const first = group[0]!; + const candidateId = createHash("sha256") + .update(key) + .digest("hex") + .slice(0, 16); + const result: CombinedCandidate = { + candidate_id: `candidate-${candidateId}`, + cwe_ids: first.cwe_ids, + locations: first.locations, + summary: mergedText(group, "summary"), + evidence: mergedText(group, "evidence"), + }; + const context = mergedText(group, "context"); + if (context) result.context = context; + if (first.instance !== undefined) result.instance = first.instance; + combined.push(result); + } + return combined; +} + +async function* lines(source: string): AsyncGenerator<[number, string]> { + const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + let remainder = ""; + let number = 0; + for await (const chunk of createReadStream(source)) { + remainder += decoder.decode(chunk as Buffer, { stream: true }); + let newline = remainder.indexOf("\n"); + while (newline !== -1) { + number += 1; + yield [number, remainder.slice(0, newline + 1)]; + remainder = remainder.slice(newline + 1); + newline = remainder.indexOf("\n"); + } + } + remainder += decoder.decode(); + if (remainder) yield [number + 1, remainder]; +} + +function expandUser(value: string): string { + if (value === "~") return homedir(); + if (value.startsWith(`~${sep}`) || (sep === "\\" && value.startsWith("~/"))) { + return join(homedir(), value.slice(2)); + } + return value; +} + +function resolveLongOption(argument: string): { + attachedValue?: string; + option: string; +} { + if (!argument.startsWith("--")) return { option: argument }; + const equals = argument.indexOf("="); + const spelling = equals === -1 ? argument : argument.slice(0, equals); + const exact = LONG_OPTIONS.find((option) => option === spelling); + const matches = + exact === undefined + ? LONG_OPTIONS.filter((option) => option.startsWith(spelling)) + : [exact]; + if (matches.length === 0) return { option: argument }; + if (matches.length > 1) { + throw new Error(`ambiguous option ${spelling}`); + } + return { + option: matches[0]!, + ...(equals === -1 ? {} : { attachedValue: argument.slice(equals + 1) }), + }; +} + +function isHelpArgument(argument: string): boolean { + if (argument === "-h" || argument === "--help") return true; + if (!argument.startsWith("--") || argument.includes("=")) return false; + const matches = [...LONG_OPTIONS, "--help"].filter((option) => + option.startsWith(argument), + ); + return matches.length === 1 && matches[0] === "--help"; +} + +function parseArguments(argv: string[]): CliArguments { + let inputs: string[] | undefined; + let output: string | undefined; + let repoRoot: string | undefined; + let scopePath: string | undefined; + let allowMissingInScope = false; + const takeValue = ( + index: number, + option: string, + attachedValue: string | undefined, + ): string => { + if (attachedValue !== undefined) return attachedValue; + const value = argv[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error(`${option}: expected a value`); + } + return value; + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + const { attachedValue, option } = resolveLongOption(argument); + if (option === "--input") { + const values: string[] = + attachedValue === undefined ? [] : [attachedValue]; + while ( + argv[index + 1] !== undefined && + !argv[index + 1]!.startsWith("-") + ) { + values.push(argv[(index += 1)]!); + } + if (values.length === 0) + throw new Error("--input: expected one or more values"); + inputs = values; + } else if (option === "--out") { + output = takeValue(index, option, attachedValue); + if (attachedValue === undefined) index += 1; + } else if (option === "--repo-root") { + repoRoot = takeValue(index, option, attachedValue); + if (attachedValue === undefined) index += 1; + } else if (option === "--in-scope-files") { + scopePath = takeValue(index, option, attachedValue); + if (attachedValue === undefined) index += 1; + } else if (option === "--allow-missing-in-scope") { + if (attachedValue !== undefined) { + throw new Error(`${option}: does not take a value`); + } + allowMissingInScope = true; + } else { + throw new Error(`unrecognized argument ${argument}`); + } + } + if (inputs === undefined) throw new Error("--input is required"); + if (output === undefined) throw new Error("--out is required"); + if (repoRoot === undefined) throw new Error("--repo-root is required"); + if (scopePath === undefined) throw new Error("--in-scope-files is required"); + return { inputs, output, repoRoot, scopePath, allowMissingInScope }; +} + +function writeCombined(output: string, rows: CombinedCandidate[]): void { + mkdirSync(dirname(output), { recursive: true }); + let temporary: string | undefined; + let descriptor: number | undefined; + try { + while (descriptor === undefined) { + temporary = join( + dirname(output), + `.${parse(output).base}.${randomBytes(8).toString("hex")}.tmp`, + ); + try { + descriptor = openSync(temporary, "wx", 0o600); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + } + } + try { + for (const row of rows) { + writeFileSync(descriptor, `${canonicalJson(row)}\n`, { + encoding: "utf8", + }); + } + } finally { + closeSync(descriptor); + descriptor = undefined; + } + renameSync(temporary!, output); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + if (temporary !== undefined) { + try { + unlinkSync(temporary); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + } + } +} + +async function normalizeCandidates( + args: CliArguments, +): Promise<[number, number, string]> { + const repoRoot = realpathSync(resolve(expandUser(args.repoRoot))); + if (!statSync(repoRoot).isDirectory()) { + throw new Error("--repo-root: expected a directory"); + } + const output = resolveAllowMissing(expandUser(args.output)); + const scopePath = realpathSync(resolve(expandUser(args.scopePath))); + const inputs = [ + ...new Set( + args.inputs.map((value) => realpathSync(resolve(expandUser(value)))), + ), + ].sort(comparePythonStrings); + if (inputs.includes(output)) + throw new Error("--out: must not also be an input"); + if (output === scopePath) { + throw new Error("--out: must not replace --in-scope-files"); + } + const scope = readScope(scopePath, repoRoot, args.allowMissingInScope); + const lineCounts = new Map(); + const rows: NormalizedCandidate[] = []; + for (const source of inputs) { + for await (const [number, line] of lines(source)) { + if (!pythonStrip(line)) continue; + try { + const value: unknown = JSON.parse(line); + if (!isObject(value)) throw new Error("expected a JSON object"); + rows.push(normalizeCandidate(value, repoRoot, scope, lineCounts)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${source} row ${number}: ${message}`); + } + } + } + const combined = combine(rows); + writeCombined(output, combined); + return [rows.length, combined.length, output]; +} + +const HELP = `Validate and combine security-scan candidates into deterministic JSONL. + +Usage: normalize_candidates.mjs --input [path ...] --out --repo-root --in-scope-files [--allow-missing-in-scope]`; + +async function runCli(): Promise { + if (process.argv.slice(2).some(isHelpArgument)) { + console.log(HELP); + return; + } + try { + const [rows, combined, output] = await normalizeCandidates( + parseArguments(process.argv.slice(2)), + ); + console.log( + `Combined ${rows} candidate rows into ${combined} rows in ${output}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`normalize_candidates: ${message}`); + process.exitCode = 2; + } +} + +const entrypoint = process.argv[1]; +if ( + entrypoint !== undefined && + import.meta.url === pathToFileURL(resolve(entrypoint)).href +) { + await runCli(); +} diff --git a/sdk/typescript/scripts/generate-plugin-helpers.mjs b/sdk/typescript/scripts/generate-plugin-helpers.mjs new file mode 100644 index 000000000..522f9c515 --- /dev/null +++ b/sdk/typescript/scripts/generate-plugin-helpers.mjs @@ -0,0 +1,39 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { format } from "prettier"; +import ts from "typescript"; + +const sourcePath = new URL( + "../plugin-helpers-src/normalize-candidates.ts", + import.meta.url, +); +const outputPath = new URL( + "../_bundled_plugin/scripts/normalize_candidates.mjs", + import.meta.url, +); +const { outputText } = ts.transpileModule(await readFile(sourcePath, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, +}); +const generated = await format( + [ + "#!/usr/bin/env node", + "// Generated from plugin-helpers-src/normalize-candidates.ts.", + "// Do not edit this file directly.", + "", + outputText, + ].join("\n"), + { filepath: fileURLToPath(outputPath) }, +); + +if (process.argv[2] === "--check") { + if ((await readFile(outputPath, "utf8")) !== generated) { + throw new Error( + "Generated plugin helpers are stale. Run `node scripts/generate-plugin-helpers.mjs`.", + ); + } +} else { + await writeFile(outputPath, generated, "utf8"); +} diff --git a/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts new file mode 100644 index 000000000..d5ab313e9 --- /dev/null +++ b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts @@ -0,0 +1,333 @@ +import { spawnSync } from "node:child_process"; +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + normalizerArguments, + runPythonNormalizer, + runTypeScriptNormalizer, + writeSource, +} from "./support/normalize-candidates.js"; + +const temporaryRoots: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; +const testWindows = process.platform === "win32" ? test : test.skip; +const directoryLinkType = process.platform === "win32" ? "junction" : "dir"; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function fixture(): { inventory: string; repository: string; root: string } { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-normalizer-filesystem-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + mkdirSync(repository); + writeSource(repository, "src/in-scope.ts", "one\ntwo\n"); + const inventory = join(root, "in-scope.txt"); + writeFileSync(inventory, "src/in-scope.ts\n"); + return { inventory, repository, root }; +} + +function candidate(path = "src/in-scope.ts"): Record { + return { + cwe_ids: ["CWE-79"], + locations: [{ path, start_line: 1, role: "source" }], + summary: "Synthetic candidate", + evidence: "Synthetic evidence", + }; +} + +function writeCandidate(root: string, row = candidate()): string { + const input = join(root, "candidates.jsonl"); + writeFileSync(input, `${JSON.stringify(row)}\n`); + return input; +} + +function temporaryOutputs(root: string, output: string): string[] { + const prefix = `.${basename(output)}.`; + return readdirSync(root).filter( + (entry) => entry.startsWith(prefix) && entry.endsWith(".tmp"), + ); +} + +describe("candidate normalizer filesystem parity", () => { + test("canonicalizes in-repository directory links and preserves hard-link names", () => { + const { inventory, repository, root } = fixture(); + writeSource(repository, "real/target.ts", "target\n"); + mkdirSync(join(repository, "aliases")); + linkSync( + join(repository, "real", "target.ts"), + join(repository, "aliases", "hard.ts"), + ); + symlinkSync( + join(repository, "real"), + join(repository, "linked"), + directoryLinkType, + ); + writeFileSync(inventory, "linked/target.ts\naliases/hard.ts\n"); + const input = writeCandidate(root, { + ...candidate(), + locations: [ + { path: "linked/target.ts", start_line: 1, role: "sink" }, + { path: "aliases/hard.ts", start_line: 1, role: "source" }, + ], + }); + const pythonOutput = join(root, "python.jsonl"); + const typescriptOutput = join(root, "typescript.jsonl"); + + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments([input], typescriptOutput, repository, inventory), + ); + + expect(pythonResult.status, pythonResult.stderr).toBe(0); + expect(typescriptResult.status, typescriptResult.stderr).toBe(0); + const expected = readFileSync(pythonOutput); + expect(readFileSync(typescriptOutput).equals(expected)).toBe(true); + expect(expected.toString("utf8")).toContain('"path":"real/target.ts"'); + expect(expected.toString("utf8")).toContain('"path":"aliases/hard.ts"'); + }); + + testPosix( + "rejects directories, broken links, and FIFOs without changing output", + () => { + const { inventory, repository, root } = fixture(); + mkdirSync(join(repository, "src", "directory")); + symlinkSync("missing.ts", join(repository, "src", "broken.ts")); + const fifo = join(repository, "src", "named-pipe"); + const mkfifo = Bun.which("mkfifo"); + expect(mkfifo).not.toBeNull(); + const fifoResult = spawnSync(mkfifo!, [fifo], { encoding: "utf8" }); + expect(fifoResult.status, fifoResult.stderr).toBe(0); + + for (const [index, path] of [ + "src/directory", + "src/broken.ts", + "src/named-pipe", + ].entries()) { + const input = join(root, `invalid-${index}.jsonl`); + writeFileSync(input, `${JSON.stringify(candidate(path))}\n`); + const sentinel = Buffer.from(`sentinel-${index}\n`); + const pythonOutput = join(root, `python-${index}.jsonl`); + const typescriptOutput = join(root, `typescript-${index}.jsonl`); + writeFileSync(pythonOutput, sentinel); + writeFileSync(typescriptOutput, sentinel); + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments([input], typescriptOutput, repository, inventory), + ); + + expect(pythonResult.status, path).toBe(2); + expect(typescriptResult.status, path).toBe(2); + expect(readFileSync(pythonOutput).equals(sentinel), path).toBe(true); + expect(readFileSync(typescriptOutput).equals(sentinel), path).toBe( + true, + ); + } + }, + ); + + test("rejects invalid UTF-8 in either input without changing output", () => { + const { inventory, repository, root } = fixture(); + const validInput = writeCandidate(root); + const cases = [ + { + input: validInput, + inventoryContents: Buffer.from([0xff, 0x0a]), + name: "scope", + }, + { + input: join(root, "invalid-utf8.jsonl"), + inventoryContents: Buffer.from("src/in-scope.ts\n"), + name: "candidate input", + }, + ]; + writeFileSync(cases[1]!.input, Buffer.from([0xff, 0x0a])); + + for (const [index, item] of cases.entries()) { + writeFileSync(inventory, item.inventoryContents); + const sentinel = Buffer.from(`sentinel-${index}\n`); + const pythonOutput = join(root, `python-utf8-${index}.jsonl`); + const typescriptOutput = join(root, `typescript-utf8-${index}.jsonl`); + writeFileSync(pythonOutput, sentinel); + writeFileSync(typescriptOutput, sentinel); + const pythonResult = runPythonNormalizer( + normalizerArguments([item.input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments( + [item.input], + typescriptOutput, + repository, + inventory, + ), + ); + + expect(pythonResult.status, item.name).toBe(2); + expect(typescriptResult.status, item.name).toBe(2); + expect(readFileSync(pythonOutput).equals(sentinel), item.name).toBe(true); + expect(readFileSync(typescriptOutput).equals(sentinel), item.name).toBe( + true, + ); + } + }); + + testPosix("rejects ambiguous carriage-return scope paths", () => { + const { inventory, repository, root } = fixture(); + writeSource(repository, "src/literal\r", "literal\n"); + writeSource(repository, "src/crlf", "crlf\n"); + writeSource(repository, "src/both", "both\n"); + writeSource(repository, "src/both\r", "both carriage\n"); + writeFileSync(inventory, "src/literal\r\nsrc/crlf\r\nsrc/both\r\n"); + const input = writeCandidate(root, candidate("src/crlf")); + const pythonResult = runPythonNormalizer( + normalizerArguments( + [input], + join(root, "python.jsonl"), + repository, + inventory, + ), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments( + [input], + join(root, "typescript.jsonl"), + repository, + inventory, + ), + ); + + expect(pythonResult.status).toBe(2); + expect(typescriptResult.status).toBe(2); + expect(pythonResult.stderr).toContain("ambiguous carriage-return paths"); + expect(typescriptResult.stderr).toContain( + "ambiguous carriage-return paths", + ); + }); + + test("protects candidate inputs and the scope inventory from output replacement", () => { + const { inventory, repository, root } = fixture(); + const input = writeCandidate(root); + const inputContents = readFileSync(input); + const inventoryContents = readFileSync(inventory); + + for (const [name, output, expected] of [ + ["input", input, inputContents], + ["scope", inventory, inventoryContents], + ] as const) { + const pythonResult = runPythonNormalizer( + normalizerArguments([input], output, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments([input], output, repository, inventory), + ); + expect(pythonResult.status, name).toBe(2); + expect(typescriptResult.status, name).toBe(2); + expect(readFileSync(output).equals(expected), name).toBe(true); + } + }); + + test("replaces output with private files and cleans failed write temporaries", () => { + const { inventory, repository, root } = fixture(); + const input = writeCandidate(root); + const pythonOutput = join(root, "python.jsonl"); + const typescriptOutput = join(root, "typescript.jsonl"); + writeFileSync(pythonOutput, "old Python output\n", { mode: 0o644 }); + writeFileSync(typescriptOutput, "old TypeScript output\n", { + mode: 0o644, + }); + + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments([input], typescriptOutput, repository, inventory), + ); + + expect(pythonResult.status, pythonResult.stderr).toBe(0); + expect(typescriptResult.status, typescriptResult.stderr).toBe(0); + expect( + readFileSync(typescriptOutput).equals(readFileSync(pythonOutput)), + ).toBe(true); + if (process.platform !== "win32") { + expect(statSync(pythonOutput).mode & 0o777).toBe(0o600); + expect(statSync(typescriptOutput).mode & 0o777).toBe(0o600); + } + expect(temporaryOutputs(root, pythonOutput)).toEqual([]); + expect(temporaryOutputs(root, typescriptOutput)).toEqual([]); + + const blockedPythonOutput = join(root, "blocked-python.jsonl"); + const blockedTypeScriptOutput = join(root, "blocked-typescript.jsonl"); + mkdirSync(blockedPythonOutput); + mkdirSync(blockedTypeScriptOutput); + const blockedPython = runPythonNormalizer( + normalizerArguments([input], blockedPythonOutput, repository, inventory), + ); + const blockedTypeScript = runTypeScriptNormalizer( + normalizerArguments( + [input], + blockedTypeScriptOutput, + repository, + inventory, + ), + ); + expect(blockedPython.status).toBe(2); + expect(blockedTypeScript.status).toBe(2); + expect(statSync(blockedPythonOutput).isDirectory()).toBe(true); + expect(statSync(blockedTypeScriptOutput).isDirectory()).toBe(true); + expect(temporaryOutputs(root, blockedPythonOutput)).toEqual([]); + expect(temporaryOutputs(root, blockedTypeScriptOutput)).toEqual([]); + }); + + testWindows("matches Python for Windows separators and drive paths", () => { + const { inventory, repository, root } = fixture(); + writeFileSync(inventory, "src\\in-scope.ts\r\n"); + const input = writeCandidate(root, candidate("src\\in-scope.ts")); + const pythonOutput = join(root, "python.jsonl"); + const typescriptOutput = join(root, "typescript.jsonl"); + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments([input], typescriptOutput, repository, inventory), + ); + expect(pythonResult.status, pythonResult.stderr).toBe(0); + expect(typescriptResult.status, typescriptResult.stderr).toBe(0); + expect( + readFileSync(typescriptOutput).equals(readFileSync(pythonOutput)), + ).toBe(true); + + writeFileSync(input, `${JSON.stringify(candidate("C:\\outside.ts"))}\n`); + expect( + runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ).status, + ).toBe(2); + expect( + runTypeScriptNormalizer( + normalizerArguments([input], typescriptOutput, repository, inventory), + ).status, + ).toBe(2); + }); +}); diff --git a/sdk/typescript/tests-ts/normalize-candidates.property.test.ts b/sdk/typescript/tests-ts/normalize-candidates.property.test.ts new file mode 100644 index 000000000..2d08ecbea --- /dev/null +++ b/sdk/typescript/tests-ts/normalize-candidates.property.test.ts @@ -0,0 +1,711 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; +import { + normalizerArguments, + runPythonNormalizer, + runTypeScriptNormalizer, + writeSource, +} from "./support/normalize-candidates.js"; +import { propertyOptions } from "./support/property.js"; + +const ROLES = [ + "entrypoint", + "entrypoint/wrapper", + "source", + "root_control", + "sink", + "concrete_implementation", + "evidence", +] as const; +const SOURCES = [ + { contents: "one\ntwo\nthree\nfour\n", lines: 4, path: "src/ascii.ts" }, + { contents: "one\r\ntwo\r\nthree", lines: 3, path: "src/é.ts" }, + { contents: "one\rtwo\rthree\r", lines: 3, path: "src/\ue000.ts" }, + { contents: "one", lines: 1, path: "src/😀.ts" }, +] as const; +const filesystemPropertyOptions = { + ...propertyOptions, + numRuns: Number(process.env["CODEX_SECURITY_PROPERTY_RUNS"] ?? "8"), +}; + +interface LocationRow { + end_line?: number; + path: string; + role: (typeof ROLES)[number]; + start_line: number; +} + +interface CandidateRow { + candidate_id?: string; + context?: string | null; + cwe_ids: string[]; + evidence: string; + instance?: string | null; + locations: LocationRow[]; + summary: string; +} + +type InvalidKind = + | "bad-cwe" + | "bad-role" + | "candidate-id" + | "empty-locations" + | "empty-summary" + | "end-before-start" + | "line-beyond-file" + | "malformed-json" + | "non-object" + | "out-of-scope" + | "path-traversal" + | "start-line-boolean" + | "unknown-candidate-field" + | "unknown-location-field"; + +const INVALID_KINDS: InvalidKind[] = [ + "bad-cwe", + "bad-role", + "candidate-id", + "empty-locations", + "empty-summary", + "end-before-start", + "line-beyond-file", + "malformed-json", + "non-object", + "out-of-scope", + "path-traversal", + "start-line-boolean", + "unknown-candidate-field", + "unknown-location-field", +]; +const VALID_EXAMPLE_ROWS: CandidateRow[] = [ + { + cwe_ids: ["CWE-79"], + locations: [{ path: "src/ascii.ts", start_line: 1, role: "source" }], + summary: "Synthetic candidate", + evidence: "Synthetic evidence", + }, +]; + +const edgeCharacter = fc.constantFrom( + "a", + "Z", + "0", + " ", + "\t", + "\r", + "\n", + "é", + "e\u0301", + "\ue000", + "😀", + "\u2028", + "\u2029", + "\0", + ":", + "\\", + "/", +); +const pythonWhitespace = fc.constantFrom( + "", + " ", + "\t", + "\r\n", + "\u001c", + "\u0085", + "\u00a0", + "\u3000", +); +const textBody = fc.oneof( + fc + .array(edgeCharacter, { maxLength: 12 }) + .map((characters) => characters.join("")), + fc.string({ unit: "binary", maxLength: 12 }), +); +const text = fc + .tuple(pythonWhitespace, textBody, pythonWhitespace) + .map(([prefix, body, suffix]) => `${prefix}x${body}y${suffix}`); +const optionalText = fc.oneof(fc.constant(undefined), fc.constant(null), text); +const cweNumber = fc.oneof( + fc.integer({ min: 1, max: 1_000_000 }).map(String), + fc.constant("9007199254740993"), + fc.constant(`1${"0".repeat(80)}`), +); +const safeFilename = fc + .array( + fc.constantFrom( + "a", + "Z", + "0", + "-", + "_", + " ", + "é", + "e\u0301", + "\ue000", + "😀", + ), + { maxLength: 12 }, + ) + .map((characters) => `file-${characters.join("")}x.ts`); +const location = fc.constantFrom(...SOURCES).chain((source) => + fc.integer({ min: 1, max: source.lines }).chain((start) => + fc + .record({ + end: fc.integer({ min: start, max: source.lines }), + includeEnd: fc.boolean(), + role: fc.constantFrom(...ROLES), + }) + .map( + ({ end, includeEnd, role }): LocationRow => ({ + path: source.path, + start_line: start, + ...(includeEnd ? { end_line: end } : {}), + role, + }), + ), + ), +); +const variant = fc.record({ + candidateId: fc.oneof(fc.constant(undefined), text), + context: optionalText, + evidence: text, + summary: text, +}); +const candidateGroup = fc + .record({ + cweNumbers: fc.uniqueArray(cweNumber, { + maxLength: 4, + selector: (value) => BigInt(value).toString(), + }), + instance: optionalText, + locations: fc.array(location, { minLength: 1, maxLength: 4 }), + variants: fc.array(variant, { minLength: 1, maxLength: 4 }), + }) + .map(({ cweNumbers, instance, locations, variants }) => + variants.map( + ({ candidateId, context, evidence, summary }, index): CandidateRow => { + const orderedLocations = ( + index % 2 === 0 ? locations : [...locations].reverse() + ).map((item) => ({ ...item })); + if (index % 3 === 0) { + orderedLocations.push({ ...orderedLocations[0]! }); + } + const formattedCwes = cweNumbers.map((number, cweIndex) => { + const prefix = (index + cweIndex) % 2 === 0 ? "CWE-" : "cwe-"; + const padding = "0".repeat((index + cweIndex) % 3); + const whitespace = (index + cweIndex) % 2 === 0 ? " " : "\u00a0"; + return `${whitespace}${prefix}${padding}${number}${whitespace}`; + }); + if (index % 2 === 1) formattedCwes.reverse(); + if (index % 3 === 0 && formattedCwes[0] !== undefined) { + formattedCwes.push(formattedCwes[0]); + } + return { + cwe_ids: formattedCwes, + locations: orderedLocations, + summary, + evidence, + ...(candidateId === undefined ? {} : { candidate_id: candidateId }), + ...(context === undefined ? {} : { context }), + ...(instance === undefined ? {} : { instance }), + }; + }, + ), + ); +const candidateRows = fc + .array(candidateGroup, { minLength: 1, maxLength: 4 }) + .map((groups) => groups.flat()); +const invalidKind = fc.constantFrom(...INVALID_KINDS); + +function fixture( + lineEnding = "\n", + finalLineEnding = true, + includeDeleted = false, +): { inventory: string; repository: string; root: string } { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-normalizer-property-")), + ); + const repository = join(root, "repository"); + mkdirSync(repository); + for (const source of SOURCES) { + writeSource(repository, source.path, source.contents); + } + writeSource(repository, "src/out-of-scope.ts", "outside\n"); + const inventory = join(root, "in-scope.txt"); + const paths: string[] = SOURCES.map((source) => source.path); + if (includeDeleted) paths.push("src/deleted.ts"); + writeFileSync( + inventory, + `${paths.join(lineEnding)}${finalLineEnding ? lineEnding : ""}`, + ); + return { inventory, repository, root }; +} + +function writeInputs( + root: string, + prefix: string, + rows: CandidateRow[], + fileCount: number, +): string[] { + const buckets = Array.from({ length: fileCount }, () => [] as string[]); + for (const [index, row] of rows.entries()) { + buckets[index % fileCount]!.push(JSON.stringify(row)); + } + return buckets.map((lines, index) => { + const path = join(root, `${prefix}-${index}.jsonl`); + writeFileSync(path, `\n${lines.join("\n\n")}\n`); + return path; + }); +} + +function inputArguments(paths: string[]): string[] { + return [...paths].reverse().concat(paths[0]!); +} + +function byteLineCount(contents: Uint8Array): number { + let lines = 0; + for (let index = 0; index < contents.length; index += 1) { + if (contents[index] === 0x0d) { + lines += 1; + if (contents[index + 1] === 0x0a) index += 1; + } else if (contents[index] === 0x0a) { + lines += 1; + } + } + const last = contents[contents.length - 1]; + return last === 0x0a || last === 0x0d ? lines : lines + 1; +} + +function pathSpelling(filename: string, variant: number): string { + switch (variant % 4) { + case 1: + return `src/./${filename}`; + case 2: + return `src//${filename}`; + case 3: + return process.platform === "win32" + ? `src\\${filename}` + : `src/${filename}`; + default: + return `src/${filename}`; + } +} + +function invalidLine(kind: InvalidKind, valid: CandidateRow): string { + if (kind === "malformed-json") return '{"cwe_ids":'; + if (kind === "non-object") return JSON.stringify([valid]); + const row = JSON.parse(JSON.stringify(valid)) as Record; + switch (kind) { + case "bad-cwe": + row["cwe_ids"] = ["CWE-0"]; + break; + case "bad-role": + row["locations"] = [ + { path: "src/ascii.ts", start_line: 1, role: "unknown" }, + ]; + break; + case "candidate-id": + row["candidate_id"] = "\u00a0\t"; + break; + case "empty-locations": + row["locations"] = []; + break; + case "empty-summary": + row["summary"] = "\u001c\u00a0\t"; + break; + case "end-before-start": + row["locations"] = [ + { + path: "src/ascii.ts", + start_line: 2, + end_line: 1, + role: "source", + }, + ]; + break; + case "line-beyond-file": + row["locations"] = [ + { path: "src/ascii.ts", start_line: 5, role: "source" }, + ]; + break; + case "out-of-scope": + row["locations"] = [ + { path: "src/out-of-scope.ts", start_line: 1, role: "source" }, + ]; + break; + case "path-traversal": + row["locations"] = [ + { path: "../outside.ts", start_line: 1, role: "source" }, + ]; + break; + case "start-line-boolean": + row["locations"] = [ + { path: "src/ascii.ts", start_line: true, role: "source" }, + ]; + break; + case "unknown-candidate-field": + row["unexpected"] = true; + break; + case "unknown-location-field": + row["locations"] = [ + { + path: "src/ascii.ts", + start_line: 1, + role: "source", + unexpected: true, + }, + ]; + break; + } + return JSON.stringify(row); +} + +function expectedError(kind: InvalidKind): string | undefined { + const messages: Partial> = { + "bad-cwe": "cwe_ids: unsupported value", + "bad-role": "role: unsupported value", + "candidate-id": "candidate_id: expected a non-empty string", + "empty-locations": "locations: expected a non-empty array", + "empty-summary": "summary: expected a non-empty string", + "end-before-start": "end_line: must be greater than or equal to start_line", + "line-beyond-file": "line range 5-5 exceeds src/ascii.ts:4", + "non-object": "expected a JSON object", + "out-of-scope": "locations: expected at least one in-scope file", + "path-traversal": + "path: expected a repository-relative path without traversal", + "start-line-boolean": "start_line: expected a positive integer", + "unknown-candidate-field": "unsupported fields unexpected", + "unknown-location-field": "locations: unsupported fields unexpected", + }; + return messages[kind]; +} + +function temporaryOutputs(root: string, output: string): string[] { + const prefix = `.${basename(output)}.`; + return readdirSync(root).filter( + (entry) => entry.startsWith(prefix) && entry.endsWith(".tmp"), + ); +} + +function normalizedStdout(stdout: string, output: string): string { + return stdout.replace(output, ""); +} + +describe("candidate normalizer differential properties", () => { + test("matches Python byte-for-byte and is invariant to order and duplicates", () => { + fc.assert( + fc.property( + candidateRows, + fc.integer({ min: 1, max: 3 }), + fc.constantFrom("\n", "\r\n"), + fc.boolean(), + fc.boolean(), + (rows, fileCount, lineEnding, finalLineEnding, includeDeleted) => { + const { inventory, repository, root } = fixture( + lineEnding, + finalLineEnding, + includeDeleted, + ); + try { + const originalInputs = inputArguments( + writeInputs(root, "original", rows, fileCount), + ); + const transformedRows = [...rows].reverse(); + transformedRows.splice( + Math.floor(transformedRows.length / 2), + 0, + rows[0]!, + ); + const transformedInputs = inputArguments( + writeInputs(root, "transformed", transformedRows, fileCount), + ); + const outputs = { + python: join(root, "python.jsonl"), + pythonTransformed: join(root, "python-transformed.jsonl"), + typescript: join(root, "typescript.jsonl"), + typescriptTransformed: join(root, "typescript-transformed.jsonl"), + }; + const allowMissing = includeDeleted; + const results = [ + runPythonNormalizer( + normalizerArguments( + originalInputs, + outputs.python, + repository, + inventory, + allowMissing, + ), + ), + runTypeScriptNormalizer( + normalizerArguments( + originalInputs, + outputs.typescript, + repository, + inventory, + allowMissing, + ), + ), + runPythonNormalizer( + normalizerArguments( + transformedInputs, + outputs.pythonTransformed, + repository, + inventory, + allowMissing, + ), + ), + runTypeScriptNormalizer( + normalizerArguments( + transformedInputs, + outputs.typescriptTransformed, + repository, + inventory, + allowMissing, + ), + ), + ]; + for (const result of results) { + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + } + expect( + normalizedStdout(results[1]!.stdout, outputs.typescript), + ).toBe(normalizedStdout(results[0]!.stdout, outputs.python)); + expect( + normalizedStdout( + results[3]!.stdout, + outputs.typescriptTransformed, + ), + ).toBe( + normalizedStdout(results[2]!.stdout, outputs.pythonTransformed), + ); + const expected = readFileSync(outputs.python); + expect(readFileSync(outputs.typescript).equals(expected)).toBe( + true, + ); + expect( + readFileSync(outputs.pythonTransformed).equals(expected), + ).toBe(true); + expect( + readFileSync(outputs.typescriptTransformed).equals(expected), + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ), + filesystemPropertyOptions, + ); + }); + + test("matches Python for generated file bytes and Unicode path spellings", () => { + fc.assert( + fc.property( + safeFilename, + fc.uint8Array({ minLength: 1, maxLength: 128 }), + fc.nat(), + fc.nat(), + fc.integer({ min: 0, max: 3 }), + fc.integer({ min: 0, max: 3 }), + fc.constantFrom(...ROLES), + ( + filename, + contents, + firstLine, + secondLine, + scopeVariant, + candidateVariant, + role, + ) => { + const { inventory, repository, root } = fixture(); + try { + const canonicalPath = `src/${filename}`; + writeSource(repository, canonicalPath, contents); + writeFileSync( + inventory, + `${pathSpelling(filename, scopeVariant)}\n`, + ); + const lineCount = byteLineCount(contents); + const left = (firstLine % lineCount) + 1; + const right = (secondLine % lineCount) + 1; + const input = join(root, "generated-path.jsonl"); + writeFileSync( + input, + `${JSON.stringify({ + cwe_ids: [], + locations: [ + { + path: pathSpelling(filename, candidateVariant), + start_line: Math.min(left, right), + end_line: Math.max(left, right), + role, + }, + ], + summary: "Generated path candidate", + evidence: "Generated path evidence", + })}\n`, + ); + const pythonOutput = join(root, "python-path.jsonl"); + const typescriptOutput = join(root, "typescript-path.jsonl"); + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments( + [input], + typescriptOutput, + repository, + inventory, + ), + ); + + expect(pythonResult.status, pythonResult.stderr).toBe(0); + expect(typescriptResult.status, typescriptResult.stderr).toBe(0); + expect(pythonResult.stderr).toBe(""); + expect(typescriptResult.stderr).toBe(""); + expect( + normalizedStdout(typescriptResult.stdout, typescriptOutput), + ).toBe(normalizedStdout(pythonResult.stdout, pythonOutput)); + expect( + readFileSync(typescriptOutput).equals(readFileSync(pythonOutput)), + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ), + filesystemPropertyOptions, + ); + }); + + test("agrees with Python on arbitrary JSON documents", () => { + fc.assert( + fc.property( + fc.jsonValue({ maxDepth: 4, stringUnit: "binary" }), + (value) => { + const { inventory, repository, root } = fixture(); + try { + const input = join(root, "arbitrary.jsonl"); + writeFileSync(input, `${JSON.stringify(value)}\n`); + const sentinel = Buffer.from("existing output\n"); + const pythonOutput = join(root, "python-arbitrary.jsonl"); + const typescriptOutput = join(root, "typescript-arbitrary.jsonl"); + writeFileSync(pythonOutput, sentinel); + writeFileSync(typescriptOutput, sentinel); + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments( + [input], + typescriptOutput, + repository, + inventory, + ), + ); + + expect(pythonResult.status === 0 || pythonResult.status === 2).toBe( + true, + ); + expect(typescriptResult.status).toBe(pythonResult.status); + if (pythonResult.status === 0) { + expect( + readFileSync(typescriptOutput).equals( + readFileSync(pythonOutput), + ), + ).toBe(true); + expect( + normalizedStdout(typescriptResult.stdout, typescriptOutput), + ).toBe(normalizedStdout(pythonResult.stdout, pythonOutput)); + } else { + expect(readFileSync(pythonOutput).equals(sentinel)).toBe(true); + expect(readFileSync(typescriptOutput).equals(sentinel)).toBe( + true, + ); + expect(pythonResult.stderr).toMatch(/^normalize_candidates:/u); + expect(typescriptResult.stderr).toMatch( + /^normalize_candidates:/u, + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ), + filesystemPropertyOptions, + ); + }); + + test("rejects the same invalid families without changing existing output", () => { + fc.assert( + fc.property( + candidateRows, + invalidKind, + fc.uint8Array({ minLength: 1, maxLength: 32 }), + (rows, kind, sentinel) => { + const { inventory, repository, root } = fixture(); + try { + const input = join(root, "invalid.jsonl"); + writeFileSync(input, `${invalidLine(kind, rows[0]!)}\n`); + const pythonOutput = join(root, "python.jsonl"); + const typescriptOutput = join(root, "typescript.jsonl"); + writeFileSync(pythonOutput, sentinel); + writeFileSync(typescriptOutput, sentinel); + const pythonResult = runPythonNormalizer( + normalizerArguments([input], pythonOutput, repository, inventory), + ); + const typescriptResult = runTypeScriptNormalizer( + normalizerArguments( + [input], + typescriptOutput, + repository, + inventory, + ), + ); + for (const result of [pythonResult, typescriptResult]) { + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toMatch(/^normalize_candidates:/u); + const message = expectedError(kind); + if (message !== undefined) { + expect(result.stderr).toContain(message); + } + } + expect( + readFileSync(pythonOutput).equals(Buffer.from(sentinel)), + ).toBe(true); + expect( + readFileSync(typescriptOutput).equals(Buffer.from(sentinel)), + ).toBe(true); + expect(temporaryOutputs(root, pythonOutput)).toEqual([]); + expect(temporaryOutputs(root, typescriptOutput)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ), + { + ...filesystemPropertyOptions, + numRuns: filesystemPropertyOptions.numRuns + INVALID_KINDS.length, + examples: INVALID_KINDS.map( + (kind, index): [CandidateRow[], InvalidKind, Uint8Array] => [ + VALID_EXAMPLE_ROWS, + kind, + Uint8Array.of(index + 1), + ], + ), + }, + ); + }); +}); diff --git a/sdk/typescript/tests-ts/normalize-candidates.test.ts b/sdk/typescript/tests-ts/normalize-candidates.test.ts new file mode 100644 index 000000000..89befb5fa --- /dev/null +++ b/sdk/typescript/tests-ts/normalize-candidates.test.ts @@ -0,0 +1,385 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + normalizerArguments as argumentsFor, + runPythonNormalizer as runPython, + runTypeScriptNormalizer as runTypeScript, + writeSource, +} from "./support/normalize-candidates.js"; + +const temporaryRoots: string[] = []; +const directoryLinkType = process.platform === "win32" ? "junction" : "dir"; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function fixture(): { root: string; repository: string } { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-normalizer-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + mkdirSync(repository); + return { root, repository }; +} + +describe("TypeScript candidate normalizer prototype", () => { + test("matches Python output for normalization, merging, and deleted scope entries", () => { + const { root, repository } = fixture(); + writeSource(repository, "src/alpha.ts", "alpha\rsecond\r"); + writeSource(repository, "src/é-handler.ts", "one\ntwo\nthree\n"); + const inventory = join(root, "in-scope.txt"); + writeFileSync( + inventory, + "src/alpha.ts\r\nsrc/é-handler.ts\r\nsrc/deleted.ts\r\n", + ); + const sharedLocations = [ + { + path: "src/é-handler.ts", + start_line: 2, + end_line: 2, + role: "sink", + }, + { path: "src/alpha.ts", start_line: 1, role: "source" }, + { path: "src/alpha.ts", start_line: 1, role: "source" }, + ]; + const firstInput = join(root, "a-candidates.jsonl"); + const secondInput = join(root, "z-candidates.jsonl"); + writeFileSync( + firstInput, + `${JSON.stringify({ + candidate_id: " ignored-upstream-id ", + cwe_ids: ["CWE-89", "cwe-079"], + locations: sharedLocations.slice().reverse(), + summary: " Résumé: missing guard ", + evidence: "earlier evidence", + context: "first context", + instance: " route:a ", + })}\n`, + ); + writeFileSync( + secondInput, + [ + "", + JSON.stringify({ + cwe_ids: [" CWE-089 ", "CWE-79", "CWE-89"], + locations: sharedLocations, + summary: "Zeta summary", + evidence: "later evidence", + context: "second context", + instance: "route:a", + }), + JSON.stringify({ + cwe_ids: ["CWE-79", "CWE-89"], + locations: sharedLocations, + summary: "\ue000 private-use summary", + evidence: "later evidence", + instance: "route:a", + }), + JSON.stringify({ + cwe_ids: ["CWE-89", "CWE-79"], + locations: sharedLocations, + summary: "😀 non-BMP summary", + evidence: "earlier evidence", + instance: "route:a", + }), + JSON.stringify({ + cwe_ids: [], + locations: [ + { path: "src/alpha.ts", start_line: 2, role: "evidence" }, + ], + summary: "Independent candidate", + evidence: "Separate identity", + }), + "", + ].join("\n"), + ); + const pythonOutput = join(root, "python.jsonl"); + const typescriptOutput = join(root, "typescript.jsonl"); + writeFileSync(pythonOutput, "stale\n"); + writeFileSync(typescriptOutput, "stale\n"); + + const pythonResult = runPython( + argumentsFor( + [secondInput, firstInput], + pythonOutput, + repository, + inventory, + true, + ), + ); + const typescriptResult = runTypeScript( + argumentsFor( + [secondInput, firstInput], + typescriptOutput, + repository, + inventory, + true, + ), + ); + + expect(pythonResult.status, pythonResult.stderr).toBe(0); + expect(typescriptResult.status, typescriptResult.stderr).toBe(0); + const expected = readFileSync(pythonOutput); + expect(readFileSync(typescriptOutput).equals(expected)).toBe(true); + expect(expected.toString("utf8")).toBe( + '{"candidate_id":"candidate-b61c9dbdc94bb668","context":"first context\\nsecond context","cwe_ids":["CWE-79","CWE-89"],"evidence":"earlier evidence\\nlater evidence","instance":"route:a","locations":[{"end_line":1,"path":"src/alpha.ts","role":"source","start_line":1},{"end_line":2,"path":"src/é-handler.ts","role":"sink","start_line":2}],"summary":"Résumé: missing guard\\nZeta summary\\n\ue000 private-use summary\\n😀 non-BMP summary"}\n' + + '{"candidate_id":"candidate-cc6760fcb9e3a98d","cwe_ids":[],"evidence":"Separate identity","locations":[{"end_line":2,"path":"src/alpha.ts","role":"evidence","start_line":2}],"summary":"Independent candidate"}\n', + ); + const rows = expected + .toString("utf8") + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(rows).toHaveLength(2); + expect(rows).toContainEqual( + expect.objectContaining({ + cwe_ids: ["CWE-79", "CWE-89"], + context: "first context\nsecond context", + evidence: "earlier evidence\nlater evidence", + summary: + "Résumé: missing guard\nZeta summary\n\ue000 private-use summary\n😀 non-BMP summary", + }), + ); + }); + + test("rejects the same semantic contract violations as Python", () => { + const { root, repository } = fixture(); + writeSource(repository, "src/in-scope.ts", "one\ntwo\n"); + writeSource(repository, "src/out-of-scope.ts", "one\n"); + writeSource(root, "outside.ts", "outside\n"); + const inventory = join(root, "in-scope.txt"); + writeFileSync(inventory, "src/in-scope.ts\n"); + const base = { + cwe_ids: ["CWE-89"], + locations: [{ path: "src/in-scope.ts", start_line: 1, role: "source" }], + summary: "Candidate", + evidence: "Evidence", + }; + const cases = [ + { + name: "unknown field", + row: { ...base, unexpected: true }, + message: "unsupported fields unexpected", + }, + { + name: "out of scope", + row: { + ...base, + locations: [ + { + path: "src/out-of-scope.ts", + start_line: 1, + role: "source", + }, + ], + }, + message: "expected at least one in-scope file", + }, + { + name: "line range", + row: { + ...base, + locations: [ + { path: "src/in-scope.ts", start_line: 3, role: "source" }, + ], + }, + message: "line range 3-3 exceeds src/in-scope.ts:2", + }, + { + name: "path traversal", + row: { + ...base, + locations: [{ path: "../outside.ts", start_line: 1, role: "source" }], + }, + message: "repository-relative path without traversal", + }, + ]; + + for (const [index, item] of cases.entries()) { + const input = join(root, `invalid-${index}.jsonl`); + writeFileSync(input, `${JSON.stringify(item.row)}\n`); + const pythonResult = runPython( + argumentsFor( + [input], + join(root, `python-${index}.jsonl`), + repository, + inventory, + ), + ); + const typescriptResult = runTypeScript( + argumentsFor( + [input], + join(root, `typescript-${index}.jsonl`), + repository, + inventory, + ), + ); + expect(pythonResult.status, item.name).toBe(2); + expect(typescriptResult.status, item.name).toBe(2); + expect(pythonResult.stderr, item.name).toContain(item.message); + expect(typescriptResult.stderr, item.name).toContain(item.message); + } + }); + + test("matches argparse equals and abbreviated long-option forms", () => { + const { root, repository } = fixture(); + writeSource(repository, "src/in-scope.ts", "one\n"); + const inventory = join(root, "in scope.txt"); + const input = join(root, "candidate input.jsonl"); + writeFileSync(inventory, "src/in-scope.ts\n"); + writeFileSync( + input, + `${JSON.stringify({ + cwe_ids: ["CWE-79"], + locations: [{ path: "src/in-scope.ts", start_line: 1, role: "source" }], + summary: "Candidate", + evidence: "Evidence", + })}\n`, + ); + const forms = [ + { + name: "equals", + args: (output: string) => [ + `--input=${input}`, + `--out=${output}`, + `--repo-root=${repository}`, + `--in-scope-files=${inventory}`, + ], + }, + { + name: "abbreviations", + args: (output: string) => [ + `--inp=${input}`, + `--o=${output}`, + `--repo=${repository}`, + `--in-s=${inventory}`, + "--a", + ], + }, + ]; + + for (const [index, form] of forms.entries()) { + const pythonOutput = join(root, `python-arguments-${index}.jsonl`); + const typescriptOutput = join( + root, + `typescript-arguments-${index}.jsonl`, + ); + const pythonResult = runPython(form.args(pythonOutput)); + const typescriptResult = runTypeScript(form.args(typescriptOutput)); + expect(pythonResult.status, form.name).toBe(0); + expect(typescriptResult.status, form.name).toBe(0); + expect( + readFileSync(typescriptOutput).equals(readFileSync(pythonOutput)), + form.name, + ).toBe(true); + } + + for (const args of [["--in"], ["--allow-missing-in-scope=true"]]) { + expect(runPython(args).status).toBe(2); + expect(runTypeScript(args).status).toBe(2); + } + }); + + test("rejects a deleted scope path through an escaping directory link", () => { + const { root, repository } = fixture(); + const outside = join(root, "outside"); + mkdirSync(outside); + symlinkSync(outside, join(repository, "linked"), directoryLinkType); + writeSource(repository, "src/in-scope.ts", "one\n"); + const inventory = join(root, "in-scope.txt"); + const input = join(root, "candidates.jsonl"); + writeFileSync(inventory, "linked/deleted.ts\nsrc/in-scope.ts\n"); + writeFileSync( + input, + `${JSON.stringify({ + cwe_ids: [], + locations: [{ path: "src/in-scope.ts", start_line: 1, role: "source" }], + summary: "Candidate", + evidence: "Evidence", + })}\n`, + ); + const pythonResult = runPython( + argumentsFor( + [input], + join(root, "python.jsonl"), + repository, + inventory, + true, + ), + ); + const typescriptResult = runTypeScript( + argumentsFor( + [input], + join(root, "typescript.jsonl"), + repository, + inventory, + true, + ), + ); + + expect(pythonResult.status).toBe(2); + expect(typescriptResult.status).toBe(2); + expect(pythonResult.stderr).toContain("path escapes repository"); + expect(typescriptResult.stderr).toContain("path escapes repository"); + }); + + test("resolves output parent components after directory links", () => { + const { root, repository } = fixture(); + writeSource(repository, "src/in-scope.ts", "one\n"); + const inventory = join(root, "in-scope.txt"); + const input = join(root, "candidates.jsonl"); + writeFileSync(inventory, "src/in-scope.ts\n"); + writeFileSync( + input, + `${JSON.stringify({ + cwe_ids: [], + locations: [{ path: "src/in-scope.ts", start_line: 1, role: "source" }], + summary: "Candidate", + evidence: "Evidence", + })}\n`, + ); + const nestedOutput = join(root, "output", "nested"); + mkdirSync(nestedOutput, { recursive: true }); + const outputLink = join(root, "output-link"); + symlinkSync(nestedOutput, outputLink, directoryLinkType); + const pythonOutput = join(root, "output", "python.jsonl"); + const typescriptOutput = join(root, "output", "typescript.jsonl"); + + const pythonResult = runPython( + argumentsFor( + [input], + `${outputLink}${sep}..${sep}python.jsonl`, + repository, + inventory, + ), + ); + const typescriptResult = runTypeScript( + argumentsFor( + [input], + `${outputLink}${sep}..${sep}typescript.jsonl`, + repository, + inventory, + ), + ); + + expect(pythonResult.status, pythonResult.stderr).toBe(0); + expect(typescriptResult.status, typescriptResult.stderr).toBe(0); + expect( + readFileSync(typescriptOutput).equals(readFileSync(pythonOutput)), + ).toBe(true); + }); +}); diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 2fc79c624..55b5e2475 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -274,7 +274,7 @@ describe("TypeScript package skeleton", () => { ); expect(packageJson.scripts.build).toBe( - "node --run clean && tsc -p tsconfig.build.json", + "node scripts/generate-plugin-helpers.mjs && node --run clean && tsc -p tsconfig.build.json", ); expect(packageJson.scripts.prepack).toBe("node --run build"); expect(packageJson.scripts["audit:prod"]).toBe( diff --git a/sdk/typescript/tests-ts/support/normalize-candidates.ts b/sdk/typescript/tests-ts/support/normalize-candidates.ts new file mode 100644 index 000000000..4b7c89ee1 --- /dev/null +++ b/sdk/typescript/tests-ts/support/normalize-candidates.ts @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); +const node = Bun.which("node"); +const pythonNormalizer = fileURLToPath( + new URL( + "../../_bundled_plugin/scripts/normalize_candidates.py", + import.meta.url, + ), +); +const typescriptNormalizer = fileURLToPath( + new URL( + "../../_bundled_plugin/scripts/normalize_candidates.mjs", + import.meta.url, + ), +); + +function executable(value: string | null, name: string): string { + if (value === null) throw new Error(`${name} is required for this test`); + return value; +} + +export function writeSource( + repository: string, + path: string, + contents: string | Uint8Array, +): void { + const output = join(repository, path); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, contents); +} + +export function runPythonNormalizer(args: string[]) { + return spawnSync( + executable(python, "Python"), + ["-B", pythonNormalizer, ...args], + { + encoding: "utf8", + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + }, + ); +} + +export function runTypeScriptNormalizer(args: string[]) { + return spawnSync( + executable(node, "Node.js"), + [typescriptNormalizer, ...args], + { + encoding: "utf8", + }, + ); +} + +export function normalizerArguments( + inputs: string[], + output: string, + repository: string, + inventory: string, + allowMissing = false, +): string[] { + return [ + "--input", + ...inputs, + "--out", + output, + "--repo-root", + repository, + "--in-scope-files", + inventory, + ...(allowMissing ? ["--allow-missing-in-scope"] : []), + ]; +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 3f9283a4f..7c5ebc070 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -1,5 +1,10 @@ { - "include": ["src/**/*.ts", "src/**/*.tsx", "tests-ts/**/*.ts"], + "include": [ + "plugin-helpers-src/**/*.ts", + "src/**/*.ts", + "src/**/*.tsx", + "tests-ts/**/*.ts" + ], "exclude": ["dist", "node_modules", "tests-ts/package.test.ts"], "compilerOptions": { "allowJs": false,