From 66e0ffa04071ba6525130660784f519c850b6476 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 23:16:55 -0700 Subject: [PATCH 1/8] feat(plugin): prototype TypeScript candidate normalization Generate the standalone helper during plugin packaging and retain Python for production. Add differential CLI, filesystem, Unicode, and property coverage, including cached-plugin upgrades and cyclic-link validation. --- .../codex-security/.codex-plugin/plugin.json | 2 +- plugins/codex-security/plugin-files.json | 1 + .../scripts/normalize_candidates.py | 2 +- .../scripts/normalize_candidates.ts | 837 ++++++++++++++++++ sdk/typescript/TESTING.md | 21 + sdk/typescript/package.json | 5 +- sdk/typescript/scripts/build-plugin.mjs | 25 +- sdk/typescript/src/version.ts | 2 +- sdk/typescript/tests-ts/build-plugin.test.ts | 11 + .../normalize-candidates-filesystem.test.ts | 512 +++++++++++ .../normalize-candidates.property.test.ts | 698 +++++++++++++++ .../tests-ts/normalize-candidates.test.ts | 435 +++++++++ sdk/typescript/tests-ts/runtime.test.ts | 20 +- .../tests-ts/support/normalize-candidates.ts | 77 ++ sdk/typescript/tsconfig.json | 1 + 15 files changed, 2642 insertions(+), 7 deletions(-) create mode 100644 plugins/codex-security/scripts/normalize_candidates.ts create mode 100644 sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts create mode 100644 sdk/typescript/tests-ts/normalize-candidates.property.test.ts create mode 100644 sdk/typescript/tests-ts/normalize-candidates.test.ts create mode 100644 sdk/typescript/tests-ts/support/normalize-candidates.ts diff --git a/plugins/codex-security/.codex-plugin/plugin.json b/plugins/codex-security/.codex-plugin/plugin.json index 72eaf506a..1261106d3 100644 --- a/plugins/codex-security/.codex-plugin/plugin.json +++ b/plugins/codex-security/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.79", + "version": "0.1.80", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/plugins/codex-security/plugin-files.json b/plugins/codex-security/plugin-files.json index 2d0004e21..7b00e9a38 100644 --- a/plugins/codex-security/plugin-files.json +++ b/plugins/codex-security/plugin-files.json @@ -50,6 +50,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/plugins/codex-security/scripts/normalize_candidates.py b/plugins/codex-security/scripts/normalize_candidates.py index a445acd70..df552e222 100644 --- a/plugins/codex-security/scripts/normalize_candidates.py +++ b/plugins/codex-security/scripts/normalize_candidates.py @@ -329,7 +329,7 @@ def main() -> None: if temporary is not None: temporary.unlink(missing_ok=True) print(f"Combined {len(rows)} candidate rows into {len(combined)} rows in {output}") - except (OSError, ValueError) as error: + except (OSError, RuntimeError, ValueError) as error: print(f"normalize_candidates: {error}", file=sys.stderr) raise SystemExit(2) from error diff --git a/plugins/codex-security/scripts/normalize_candidates.ts b/plugins/codex-security/scripts/normalize_candidates.ts new file mode 100644 index 000000000..a7e0f6b47 --- /dev/null +++ b/plugins/codex-security/scripts/normalize_candidates.ts @@ -0,0 +1,837 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + closeSync, + createReadStream, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { EOL, homedir } from "node:os"; +import { + basename, + dirname, + isAbsolute, + join, + parse, + relative, + resolve, + sep, +} from "node:path"; +import { fileURLToPath } from "node:url"; + +const CWE = /^CWE-(\p{Decimal_Number}+)$/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 = [ + "--help", + "--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 : decimalInteger(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 decimalInteger(value: string): bigint { + return BigInt( + Array.from(value, (digit) => { + const point = digit.codePointAt(0)!; + let start = point; + // Unicode decimal digits form consecutive sets of ten, sometimes adjacent. + while (/\p{Decimal_Number}/u.test(String.fromCodePoint(start - 1))) + start -= 1; + return (point - start) % 10; + }).join(""), + ); +} + +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 resolveAllowMissing(value: string): string { + const absolute = + process.platform === "win32" + ? resolve(value) + : 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: (string | { symlink: string })[] = initialPath.parts; + let current = initialPath.root; + let index = 0; + const activeLinks = new Set(); + while (index < parts.length) { + const component = parts[index++]!; + if (typeof component !== "string") { + activeLinks.delete(component.symlink); + continue; + } + 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); + if (activeLinks.has(candidate)) { + throw new Error(`too many symbolic links while resolving ${value}`); + } + activeLinks.add(candidate); + current = isAbsolute(target) ? targetPath.root : dirname(candidate); + parts = [...targetPath.parts, { symlink: candidate }, ...remainder]; + index = 0; + } + return current; +} + +export function readScope( + scopePath: string, + repoRoot: string, + allowMissing = false, +): Set { + const contents = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(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 }); + // Keep a trailing carriage return until the next chunk so CRLF stays together. + const newline = /\r\n|\n|\r(?!$)/u; + let remainder = ""; + let number = 0; + for await (const chunk of createReadStream(source)) { + remainder += decoder.decode(chunk as Buffer, { stream: true }); + let boundary = newline.exec(remainder); + while (boundary !== null) { + const end = boundary.index + boundary[0].length; + number += 1; + yield [number, remainder.slice(0, end)]; + remainder = remainder.slice(end); + boundary = newline.exec(remainder); + } + } + remainder += decoder.decode(); + if (remainder) yield [number + 1, remainder]; +} + +function expandUser(value: string): string { + if (!value.startsWith("~")) return value; + const boundary = value.search( + process.platform === "win32" ? /[\\/]/u : /\//u, + ); + const end = boundary === -1 ? value.length : boundary; + const userName = value.slice(1, end); + let userDirectory = homedir(); + if (userName && process.platform === "win32") { + const currentUser = process.env["USERNAME"]; + if (userName !== currentUser) { + if (basename(userDirectory) !== currentUser) { + throw new Error("Could not determine home directory."); + } + userDirectory = join(dirname(userDirectory), userName); + } + } else if (userName) { + const account = + process.platform === "darwin" + ? execFileSync("dscacheutil", ["-q", "user", "-a", "name", userName], { + encoding: "utf8", + }) + : execFileSync("getent", ["passwd", userName], { encoding: "utf8" }); + const directory = + process.platform === "darwin" + ? /^dir: (.*)$/mu.exec(account)?.[1] + : account.trimEnd().split(":")[5]; + if (directory === undefined) + throw new Error("Could not determine home directory."); + userDirectory = directory; + } + return userDirectory + value.slice(end); +} + +function isArgumentValue(value: string): boolean { + return ( + !value.startsWith("-") || + value === "-" || + /^-(?:\p{Decimal_Number}+|\p{Decimal_Number}*\.\p{Decimal_Number}+)$/u.test( + value, + ) + ); +} + +function resolveLongOption(argument: string): { + attachedValue?: string; + option: string; +} { + if (argument === "-h") return { option: "--help" }; + 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 parseArguments(argv: string[]): CliArguments | undefined { + let inputs: string[] | undefined; + let output: string | undefined; + let repoRoot: string | undefined; + let scopePath: string | undefined; + let allowMissingInScope = false; + const unrecognized: string[] = []; + const takeValue = ( + index: number, + option: string, + attachedValue: string | undefined, + ): string => { + if (attachedValue !== undefined) return attachedValue; + const value = argv[index + 1]; + if (value === undefined || !isArgumentValue(value)) { + 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 && + isArgumentValue(argv[index + 1]!) + ) { + 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" || option === "--help") { + if (attachedValue !== undefined) { + throw new Error(`${option}: does not take a value`); + } + if (option === "--help") return undefined; + allowMissingInScope = true; + } else { + unrecognized.push(argument); + } + } + if (unrecognized.length > 0) + throw new Error(`unrecognized arguments ${unrecognized.join(" ")}`); + 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 }); + const directory = mkdtempSync( + join(dirname(output), `.${parse(output).base}.`), + ); + const temporary = join(directory, "output"); + try { + const descriptor = openSync(temporary, "wx", 0o600); + try { + for (const row of rows) { + writeFileSync(descriptor, `${canonicalJson(row)}${EOL}`, { + encoding: "utf8", + }); + } + } finally { + closeSync(descriptor); + } + renameSync(temporary, output); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +async function normalizeCandidates( + args: CliArguments, +): Promise<[number, number, string]> { + const repoRoot = realpathSync.native(expandUser(args.repoRoot)); + if (!statSync(repoRoot).isDirectory()) { + throw new Error("--repo-root: expected a directory"); + } + const output = resolveAllowMissing(expandUser(args.output)); + const scopePath = realpathSync.native(expandUser(args.scopePath)); + const inputs = [ + ...new Set( + args.inputs.map((value) => realpathSync.native(expandUser(value))), + ), + ].sort(comparePythonStrings); + if (inputs.some((input) => relative(input, output) === "")) + throw new Error("--out: must not also be an input"); + if (relative(scopePath, output) === "") { + 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, + (key, value: unknown, context?: { source?: string }) => { + if ( + (key === "start_line" || key === "end_line") && + typeof value === "number" && + /[.eE]/u.test(context?.source ?? "") + ) { + throw new Error(`${key}: expected a positive integer`); + } + return value; + }, + ); + 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 { + try { + const args = parseArguments(process.argv.slice(2)); + if (args === undefined) { + process.stdout.write(`${HELP.replaceAll("\n", EOL)}${EOL}`); + return; + } + const [rows, combined, output] = await normalizeCandidates(args); + process.stdout.write( + `Combined ${rows} candidate rows into ${combined} rows in ${output}${EOL}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`normalize_candidates: ${message}${EOL}`); + process.exitCode = 2; + } +} + +const entrypoint = process.argv[1]; +if ( + entrypoint !== undefined && + realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entrypoint) +) { + await runCli(); +} diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 261ce99c6..2b972c920 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -79,6 +79,27 @@ 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. + +The normalizer source lives in `plugins/codex-security/scripts/normalize_candidates.ts`. +`build:plugin` compiles it into the ignored `_bundled_plugin` payload alongside +the Python helper. The differential command rebuilds that payload first. +Named-user home paths use OS account lookup: `dscacheutil` on macOS and +`getent` on Linux. + +```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/package.json b/sdk/typescript/package.json index fa937b0c9..1ff9197dd 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -48,7 +48,7 @@ "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", - "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,tsx,json,md}\"", + "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,tsx,json,md}\" \"../../plugins/codex-security/scripts/*.ts\"", "generate:models": "node scripts/generate-models.cjs", "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", @@ -59,7 +59,8 @@ "test:mcp": "node --run build:plugin && pnpm --dir ../../plugins/codex-security/mcp-app run test:mcp", "test:mutation": "stryker run", "test:package": "node scripts/smoke-package.mjs", - "types": "pnpm run generate:models:check && pnpm --dir ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit" + "types": "pnpm run generate:models:check && pnpm --dir ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit", + "test:normalizer-differential": "node --run build:plugin && bun test --timeout 30000 tests-ts/normalize-candidates.test.ts tests-ts/normalize-candidates-filesystem.test.ts tests-ts/normalize-candidates.property.test.ts" }, "dependencies": { "@inquirer/prompts": "8.3.0", diff --git a/sdk/typescript/scripts/build-plugin.mjs b/sdk/typescript/scripts/build-plugin.mjs index 7f8dbbbce..647a3a1c0 100644 --- a/sdk/typescript/scripts/build-plugin.mjs +++ b/sdk/typescript/scripts/build-plugin.mjs @@ -7,6 +7,7 @@ import { readFile, readdir, rm, + writeFile, } from "node:fs/promises"; import { dirname, join, posix, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -16,6 +17,7 @@ const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(scriptDirectory, ".."); const repositoryRoot = resolve(packageRoot, "../.."); const publicManifest = ".codex-plugin/plugin.json"; +const normalizer = "scripts/normalize_candidates.mjs"; const execFileAsync = promisify(execFile); function sourcePath(root, relativePath) { @@ -89,7 +91,9 @@ export async function buildBundledPlugin({ throw new Error("Plugin projection contract contains duplicate paths."); } - const copiedPaths = files.filter((path) => !path.startsWith("mcp/")); + const copiedPaths = files.filter( + (path) => !path.startsWith("mcp/") && path !== normalizer, + ); const sourceFiles = await Promise.all( copiedPaths.map(async (path) => { const file = sourcePath(source, path); @@ -131,6 +135,25 @@ export async function buildBundledPlugin({ await chmod(output, mode); } + if (files.includes(normalizer)) { + const { transform } = await import("esbuild"); + const { code } = await transform( + await readFile( + sourcePath(source, "scripts/normalize_candidates.ts"), + "utf8", + ), + { + loader: "ts", + format: "esm", + target: "node22.13", + banner: "#!/usr/bin/env node", + }, + ); + const output = sourcePath(destination, normalizer); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, code, "utf8"); + } + const generated = await destinationFiles(destination); const expected = [...files].sort(); if ( diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 0fcfbbb6d..312e697bc 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.79" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.80" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index b1097d920..bf68ebeb9 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -167,6 +167,11 @@ describe("bundled plugin build", () => { await writeFixture(source, "scripts/launch", "#!/bin/sh\nexit 0\n"); await chmod(join(source, "scripts", "launch"), 0o755); await writeFixture(source, "schemas/scan.json", "{}\n"); + await writeFixture( + source, + "scripts/normalize_candidates.ts", + 'const message: string = "generated normalizer";\nconsole.log(message);\n', + ); await writeFixture( source, "mcp-app/package.json", @@ -202,6 +207,7 @@ await writeFile(join(output, "server.mjs"), "generated mcp runtime\\n"); "mcp/server.mjs", "schemas/scan.json", "scripts/launch", + "scripts/normalize_candidates.mjs", "sdk/typescript/owned-by-sdk.txt", ], }, @@ -217,6 +223,7 @@ await writeFile(join(output, "server.mjs"), "generated mcp runtime\\n"); "mcp/server.mjs", "schemas/scan.json", "scripts/launch", + "scripts/normalize_candidates.mjs", ]); expect( await readFile(join(destination, "schemas", "scan.json"), "utf8"), @@ -224,6 +231,10 @@ await writeFile(join(output, "server.mjs"), "generated mcp runtime\\n"); expect(await readFile(join(destination, "mcp", "server.mjs"), "utf8")).toBe( "generated mcp runtime\n", ); + const normalizer = await execFileAsync("node", [ + join(destination, "scripts", "normalize_candidates.mjs"), + ]); + expect(normalizer.stdout.trim()).toBe("generated normalizer"); if (process.platform !== "win32") { expect( (await stat(join(destination, "scripts", "launch"))).mode & 0o111, 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..6e3976e3f --- /dev/null +++ b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts @@ -0,0 +1,512 @@ +import { spawnSync } from "node:child_process"; +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import { join, relative, sep } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; +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; +} + +describe("candidate normalizer filesystem parity", () => { + testPosix( + "resolves CLI paths after directory links and parent components", + () => { + const { repository, root } = fixture(); + writeCandidate(repository); + writeFileSync(join(repository, "in-scope.txt"), "src/in-scope.ts\n"); + const nested = join(repository, "nested"); + mkdirSync(nested); + const linked = join(root, "linked"); + symlinkSync(nested, linked, "dir"); + const outputs: Buffer[] = []; + for (const [name, run] of [ + ["python", runPythonNormalizer], + ["typescript", runTypeScriptNormalizer], + ] as const) { + const output = join(root, `${name}.jsonl`); + const result = run( + normalizerArguments( + [`${linked}/../candidates.jsonl`], + output, + `${linked}/..`, + `${linked}/../in-scope.txt`, + ), + ); + expect(result.status, result.stderr).toBe(0); + outputs.push(readFileSync(output)); + } + expect(outputs[1]).toEqual(outputs[0]); + }, + ); + + testPosix( + "rejects expanding symlink loops and permits repeated non-cyclic links", + () => { + const { inventory, repository, root } = fixture(); + const input = writeCandidate(root); + const loop = join(root, "loop"); + symlinkSync("loop/child", loop, "dir"); + const repeated = join(root, "repeated"); + symlinkSync(root, repeated, "dir"); + const outputs: Buffer[] = []; + for (const [name, run] of [ + ["python", runPythonNormalizer], + ["typescript", runTypeScriptNormalizer], + ] as const) { + const invalid = run( + normalizerArguments( + [input], + join(loop, `${name}.jsonl`), + repository, + inventory, + ), + undefined, + { timeout: 5_000 }, + ); + expect(invalid.error).toBeUndefined(); + expect(invalid.status, invalid.stderr).toBe(2); + expect(invalid.stdout).toBe(""); + const result = run( + normalizerArguments( + [input], + join(repeated, "repeated", `${name}.jsonl`), + repository, + inventory, + ), + ); + expect(result.status, result.stderr).toBe(0); + outputs.push(readFileSync(join(root, `${name}.jsonl`))); + } + expect(outputs[1]).toEqual(outputs[0]); + }, + ); + + test("expands named-user home paths before resolving CLI paths", () => { + const { inventory, repository, root } = fixture(); + const input = writeCandidate(root); + const user = userInfo(); + const userName = + process.platform === "win32" ? process.env["USERNAME"]! : user.username; + const userDirectory = + process.platform === "win32" ? process.env["USERPROFILE"]! : user.homedir; + const namedPath = (path: string) => + `~${userName}${sep}${relative(userDirectory, path)}`; + const outputs: Buffer[] = []; + for (const [name, run] of [ + ["python", runPythonNormalizer], + ["typescript", runTypeScriptNormalizer], + ] as const) { + const output = join(root, `${name}.jsonl`); + const result = run( + normalizerArguments( + [namedPath(input)], + namedPath(output), + namedPath(repository), + namedPath(inventory), + ), + ); + expect(result.status, result.stderr).toBe(0); + outputs.push(readFileSync(output)); + } + expect(outputs[1]).toEqual(outputs[0]); + }); + + test.each(["1.0", "1e0"])( + "rejects floating line tokens %s without replacing output", + (token) => { + const { inventory, repository, root } = fixture(); + const input = join(root, "candidates.jsonl"); + for (const field of ["start_line", "end_line"]) { + const row = { + ...candidate(), + locations: [ + { + path: "src/in-scope.ts", + start_line: 1, + end_line: 1, + role: "source", + }, + ], + }; + writeFileSync( + input, + JSON.stringify(row).replace(`"${field}":1`, `"${field}":${token}`) + + "\n", + ); + for (const [name, run] of [ + ["python", runPythonNormalizer], + ["typescript", runTypeScriptNormalizer], + ] as const) { + const output = join(root, `${name}.jsonl`); + writeFileSync(output, "existing output\n"); + const result = run( + normalizerArguments([input], output, repository, inventory), + ); + expect(result.status, `${name} ${field}=${token}`).toBe(2); + expect(result.stderr).toContain( + `${field}: expected a positive integer`, + ); + expect(readFileSync(output, "utf8")).toBe("existing output\n"); + } + } + }, + ); + + test("runs through a linked helper directory", () => { + const { inventory, repository, root } = fixture(); + const input = writeCandidate(root); + const linked = join(root, "linked-helpers"); + symlinkSync(join(PLUGIN_ROOT, "scripts"), linked, directoryLinkType); + const outputs: Buffer[] = []; + for (const [name, script, run] of [ + ["python", "normalize_candidates.py", runPythonNormalizer], + ["typescript", "normalize_candidates.mjs", runTypeScriptNormalizer], + ] as const) { + const output = join(root, `${name}.jsonl`); + const result = run( + normalizerArguments([input], output, repository, inventory), + join(linked, script), + ); + expect(result.status, result.stderr).toBe(0); + outputs.push(readFileSync(output)); + } + expect(outputs[1]).toEqual(outputs[0]); + }); + + 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 before = readdirSync(root).sort(); + + 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(readdirSync(root).sort()).toEqual(before); + + const blockedPythonOutput = join(root, "blocked-python.jsonl"); + const blockedTypeScriptOutput = join(root, "blocked-typescript.jsonl"); + mkdirSync(blockedPythonOutput); + mkdirSync(blockedTypeScriptOutput); + const beforeFailure = readdirSync(root).sort(); + 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(readdirSync(root).sort()).toEqual(beforeFailure); + }); + + 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); + }); + + testWindows("protects differently cased input and scope paths", () => { + const { inventory, repository, root } = fixture(); + const input = writeCandidate(root); + for (const output of [input, inventory]) { + const before = readFileSync(output); + for (const run of [runPythonNormalizer, runTypeScriptNormalizer]) { + const result = run( + normalizerArguments( + [input], + output.toUpperCase(), + repository, + inventory, + ), + ); + expect(result.status, result.stderr).toBe(2); + expect(readFileSync(output)).toEqual(before); + } + } + }); +}); 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..eb5621108 --- /dev/null +++ b/sdk/typescript/tests-ts/normalize-candidates.property.test.ts @@ -0,0 +1,698 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { 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; +} + +const INVALID_KINDS = [ + "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", +] as const; +type InvalidKind = (typeof INVALID_KINDS)[number]; +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"; + const digits = Array.from( + ["0123456789", "٠١٢٣٤٥٦٧٨٩", "0123456789", "𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡"][ + (index + cweIndex) % 4 + ]!, + ); + const value = `${padding}${number}`.replace( + /\d/gu, + (digit) => digits[Number(digit)]!, + ); + return `${whitespace}${prefix}${value}${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 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 before = readdirSync(root).sort(); + 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(readdirSync(root).sort()).toEqual(before); + } 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..b0684c110 --- /dev/null +++ b/sdk/typescript/tests-ts/normalize-candidates.test.ts @@ -0,0 +1,435 @@ +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.each(["\n", "\r\n", "\r"])( + "matches Python normalization with JSONL separator %j", + (inputNewline) => { + 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", "CWE-٨٩", "CWE-79", "CWE-𝟠𝟡"], + locations: sharedLocations.slice().reverse(), + summary: " Résumé: missing guard ", + evidence: "earlier evidence", + context: "first context", + instance: " route:a ", + })}${inputNewline}`, + ); + 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(inputNewline), + ); + 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").replaceAll("\r\n", "\n")).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', + ); + }, + ); + + 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("validates option values before processing help", () => { + for (const args of [ + ["--help"], + ["-h"], + ["--he"], + ["--help", "--out"], + ["--unknown", "--help"], + ["--out", "--help"], + ["--input", "--help"], + ["--repo-root", "-h"], + ["--in-scope-files", "--help"], + ["--help=value"], + ]) { + const expected = runPython(args); + const actual = runTypeScript(args); + expect(actual.status, args.join(" ")).toBe(expected.status); + if (expected.status === 2) expect(actual.stdout).toBe(""); + } + }); + + test("accepts negative-number filenames for scalar and multi-value options", () => { + const { root } = fixture(); + const repository = join(root, "-3"); + writeSource(repository, "source.ts", "one\n"); + writeFileSync(join(root, "-2"), "source.ts\n"); + writeFileSync( + join(root, "-1"), + JSON.stringify({ + cwe_ids: ["CWE-79"], + locations: [{ path: "source.ts", start_line: 1, role: "source" }], + summary: "Synthetic candidate", + evidence: "Synthetic evidence", + }) + "\n", + ); + for (const [run, output] of [ + [runPython, "-4"], + [runTypeScript, "-5"], + ] as const) { + const result = run(argumentsFor(["-1"], output, "-3", "-2"), undefined, { + cwd: root, + }); + expect(result.status, result.stderr).toBe(0); + } + expect(readFileSync(join(root, "-5"))).toEqual( + readFileSync(join(root, "-4")), + ); + for (const option of [ + "--input", + "--out", + "--repo-root", + "--in-scope-files", + ]) { + for (const value of ["-", "-.5", "-1.5", "-١"]) { + const args = [option, value, "--help"]; + expect(runPython(args).status).toBe(0); + expect(runTypeScript(args).status).toBe(0); + } + } + }); + + 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 outputParent = + process.platform === "win32" ? root : join(root, "output"); + const pythonOutput = join(outputParent, "python.jsonl"); + const typescriptOutput = join(outputParent, "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/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 07c142f1f..09441855f 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1963,6 +1963,10 @@ describe("plugin runtime preparation", () => { test("upgrades the predecessor cache and restores with the SDK-owned helper", async () => { const root = await temporaryDirectory(); const previous = await plugin(join(root, "previous"), "0.1.60"); + await writeFile( + join(previous, "scripts", "normalize_candidates.mjs"), + "throw new Error('stale normalizer must be replaced');\n", + ); // Keep the stale MCP configuration regression covered while upgrading the // current predecessor cache to the generated bundle. await writeFile( @@ -2009,11 +2013,25 @@ describe("plugin runtime preparation", () => { expect(upgraded.version).toBe(BUNDLED_PLUGIN_VERSION); expect(upgraded.version).not.toBe(stale.version); expect(upgraded.installedRoot).not.toBe(stale.installedRoot); - for (const script of ["workbench_target.py", "finalize_scan_contract.py"]) { + for (const script of [ + "workbench_target.py", + "finalize_scan_contract.py", + "normalize_candidates.mjs", + ]) { expect( await readFile(join(upgraded.installedRoot, "scripts", script)), ).toEqual(await readFile(join(PLUGIN_ROOT, "scripts", script))); } + const help = spawnSync( + "node", + [ + join(upgraded.installedRoot, "scripts", "normalize_candidates.mjs"), + "--help", + ], + { encoding: "utf8" }, + ); + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain("Usage:"); const configuration = JSON.parse( await readFile(join(upgraded.installedRoot, ".mcp.json"), "utf8"), ) as { 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..368278287 --- /dev/null +++ b/sdk/typescript/tests-ts/support/normalize-candidates.ts @@ -0,0 +1,77 @@ +import { spawnSync, type SpawnSyncOptions } 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[], + script = pythonNormalizer, + options: Pick = {}, +) { + return spawnSync(executable(python, "Python"), ["-B", script, ...args], { + ...options, + encoding: "utf8", + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + }); +} + +export function runTypeScriptNormalizer( + args: string[], + script = typescriptNormalizer, + options: Pick = {}, +) { + return spawnSync(executable(node, "Node.js"), [script, ...args], { + ...options, + 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 18454c78e..2ca9f548a 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -1,5 +1,6 @@ { "include": [ + "../../plugins/codex-security/scripts/normalize_candidates.ts", "src/**/*.ts", "src/**/*.tsx", "dashboard/**/*.ts", From 6e3b58f9bc80b1d9adc9268f09fbd4d5bc1f097c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 23:19:34 -0700 Subject: [PATCH 2/8] test(plugin): keep named-home fixtures portable across drives --- .../normalize-candidates-filesystem.test.ts | 19 +++++++++++++++++-- .../tests-ts/support/normalize-candidates.ts | 7 ++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts index 6e3976e3f..ac53131ce 100644 --- a/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts +++ b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts @@ -140,9 +140,22 @@ describe("candidate normalizer filesystem parity", () => { const input = writeCandidate(root); const user = userInfo(); const userName = - process.platform === "win32" ? process.env["USERNAME"]! : user.username; + process.platform === "win32" ? "named-user" : user.username; const userDirectory = - process.platform === "win32" ? process.env["USERPROFILE"]! : user.homedir; + process.platform === "win32" + ? join(root, "profiles", userName) + : user.homedir; + const environment = + process.platform === "win32" + ? { + USERNAME: "current-user", + USERPROFILE: join(root, "profiles", "current-user"), + } + : undefined; + if (environment !== undefined) { + mkdirSync(userDirectory, { recursive: true }); + mkdirSync(environment.USERPROFILE, { recursive: true }); + } const namedPath = (path: string) => `~${userName}${sep}${relative(userDirectory, path)}`; const outputs: Buffer[] = []; @@ -158,6 +171,8 @@ describe("candidate normalizer filesystem parity", () => { namedPath(repository), namedPath(inventory), ), + undefined, + { env: environment }, ); expect(result.status, result.stderr).toBe(0); outputs.push(readFileSync(output)); diff --git a/sdk/typescript/tests-ts/support/normalize-candidates.ts b/sdk/typescript/tests-ts/support/normalize-candidates.ts index 368278287..93a521d48 100644 --- a/sdk/typescript/tests-ts/support/normalize-candidates.ts +++ b/sdk/typescript/tests-ts/support/normalize-candidates.ts @@ -36,23 +36,24 @@ export function writeSource( export function runPythonNormalizer( args: string[], script = pythonNormalizer, - options: Pick = {}, + options: Pick = {}, ) { return spawnSync(executable(python, "Python"), ["-B", script, ...args], { ...options, encoding: "utf8", - env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + env: { ...process.env, ...options.env, PYTHONDONTWRITEBYTECODE: "1" }, }); } export function runTypeScriptNormalizer( args: string[], script = typescriptNormalizer, - options: Pick = {}, + options: Pick = {}, ) { return spawnSync(executable(node, "Node.js"), [script, ...args], { ...options, encoding: "utf8", + env: { ...process.env, ...options.env }, }); } From 69c9770223d9246327fefd915beec06687888ff9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 23:22:38 -0700 Subject: [PATCH 3/8] fix(plugin): preserve argparse attached-input arity --- .../codex-security/scripts/normalize_candidates.ts | 1 + sdk/typescript/tests-ts/normalize-candidates.test.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/normalize_candidates.ts b/plugins/codex-security/scripts/normalize_candidates.ts index a7e0f6b47..02ce99967 100644 --- a/plugins/codex-security/scripts/normalize_candidates.ts +++ b/plugins/codex-security/scripts/normalize_candidates.ts @@ -695,6 +695,7 @@ function parseArguments(argv: string[]): CliArguments | undefined { const values: string[] = attachedValue === undefined ? [] : [attachedValue]; while ( + attachedValue === undefined && argv[index + 1] !== undefined && isArgumentValue(argv[index + 1]!) ) { diff --git a/sdk/typescript/tests-ts/normalize-candidates.test.ts b/sdk/typescript/tests-ts/normalize-candidates.test.ts index b0684c110..2c00a9568 100644 --- a/sdk/typescript/tests-ts/normalize-candidates.test.ts +++ b/sdk/typescript/tests-ts/normalize-candidates.test.ts @@ -276,7 +276,17 @@ describe("TypeScript candidate normalizer prototype", () => { ).toBe(true); } - for (const args of [["--in"], ["--allow-missing-in-scope=true"]]) { + for (const args of [ + ["--in"], + ["--allow-missing-in-scope=true"], + [ + `--input=${input}`, + input, + `--out=${join(root, "unbound-input.jsonl")}`, + `--repo-root=${repository}`, + `--in-scope-files=${inventory}`, + ], + ]) { expect(runPython(args).status).toBe(2); expect(runTypeScript(args).status).toBe(2); } From a562f4dc02a8d6f3f99b429996e302c1a3b79f52 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 07:21:58 -0700 Subject: [PATCH 4/8] refactor(plugin): simplify TypeScript candidate normalization --- .../scripts/normalize_candidates.py | 2 +- .../scripts/normalize_candidates.ts | 906 +++++------------- sdk/typescript/TESTING.md | 32 +- sdk/typescript/package.json | 3 +- .../normalize-candidates-filesystem.test.ts | 594 ++++-------- .../normalize-candidates.property.test.ts | 741 ++------------ .../tests-ts/normalize-candidates.test.ts | 537 +++-------- .../tests-ts/support/normalize-candidates.ts | 42 +- 8 files changed, 627 insertions(+), 2230 deletions(-) diff --git a/plugins/codex-security/scripts/normalize_candidates.py b/plugins/codex-security/scripts/normalize_candidates.py index df552e222..a445acd70 100644 --- a/plugins/codex-security/scripts/normalize_candidates.py +++ b/plugins/codex-security/scripts/normalize_candidates.py @@ -329,7 +329,7 @@ def main() -> None: if temporary is not None: temporary.unlink(missing_ok=True) print(f"Combined {len(rows)} candidate rows into {len(combined)} rows in {output}") - except (OSError, RuntimeError, ValueError) as error: + except (OSError, ValueError) as error: print(f"normalize_candidates: {error}", file=sys.stderr) raise SystemExit(2) from error diff --git a/plugins/codex-security/scripts/normalize_candidates.ts b/plugins/codex-security/scripts/normalize_candidates.ts index 02ce99967..4ab22ffe5 100644 --- a/plugins/codex-security/scripts/normalize_candidates.ts +++ b/plugins/codex-security/scripts/normalize_candidates.ts @@ -1,43 +1,27 @@ -import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { - closeSync, - createReadStream, - lstatSync, mkdirSync, mkdtempSync, - openSync, readFileSync, - readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; -import { EOL, homedir } from "node:os"; -import { - basename, - dirname, - isAbsolute, - join, - parse, - relative, - resolve, - sep, -} from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; - -const CWE = /^CWE-(\p{Decimal_Number}+)$/iu; -const ROLES = new Map([ - ["entrypoint", 0], - ["entrypoint/wrapper", 1], - ["source", 2], - ["root_control", 3], - ["sink", 4], - ["concrete_implementation", 5], - ["evidence", 6], -]); +import { parseArgs } from "node:util"; + +const ROLES = [ + "entrypoint", + "entrypoint/wrapper", + "source", + "root_control", + "sink", + "concrete_implementation", + "evidence", +] as const; const FIELDS = new Set([ "candidate_id", "cwe_ids", @@ -48,25 +32,12 @@ const FIELDS = new Set([ "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; + role: (typeof ROLES)[number]; } interface NormalizedCandidate { @@ -78,761 +49,328 @@ interface NormalizedCandidate { instance?: string; } -interface CombinedCandidate extends NormalizedCandidate { - candidate_id: string; -} - -interface CliArguments { - inputs: string[]; - output: string; - repoRoot: string; - scopePath: string; - allowMissingInScope: boolean; -} - -const LONG_OPTIONS = [ - "--help", - "--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, ""); -} +type CombinedCandidate = NormalizedCandidate & { candidate_id: string }; -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; +function object( + value: unknown, + fields: ReadonlySet, +): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected an object"); } - throw new Error("expected a JSON value"); + const extra = Object.keys(value).filter((key) => !fields.has(key)); + if (extra.length) throw new Error(`unsupported fields: ${extra.join(", ")}`); + return value as Record; } -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)) { +function text(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) { throw new Error(`${field}: expected a non-empty string`); } - return pythonStrip(value); + return value.trim(); } -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 : decimalInteger(match[1]); - if (match === null || number < 1n) { - throw new Error(`cwe_ids: unsupported value ${JSON.stringify(item)}`); - } - found.add(number.toString()); +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 [...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 decimalInteger(value: string): bigint { - return BigInt( - Array.from(value, (digit) => { - const point = digit.codePointAt(0)!; - let start = point; - // Unicode decimal digits form consecutive sets of ten, sometimes adjacent. - while (/\p{Decimal_Number}/u.test(String.fromCodePoint(start - 1))) - start -= 1; - return (point - start) % 10; - }).join(""), - ); + return value; } -function errorCode(error: unknown): string | undefined { - return error instanceof Error - ? (error as NodeJS.ErrnoException).code - : undefined; +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); } -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 isMissingFile(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; } -function posixParts(value: string): string[] { - return value.split("/").filter((part) => part !== "" && part !== "."); +function readLines(path: string): string[] { + return new TextDecoder("utf-8", { fatal: true }) + .decode(readFileSync(path)) + .split(/\r?\n/u); } -export function relativeFile( - value: unknown, - repoRoot: string, -): [string, string] { +function repoFile(value: unknown, repoRoot: string) { if (typeof value !== "string" || !value || value.includes("\0")) { - throw new Error("path: expected a non-empty repository-relative path"); + throw new Error("path: expected a repository-relative file"); } const raw = process.platform === "win32" ? value.replaceAll("\\", "/") : value; - const parts = posixParts(raw); if ( - raw.startsWith("/") || - parts.includes("..") || + isAbsolute(raw) || + raw.split("/").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) { + const source = realpathSync.native(resolve(repoRoot, raw)); + const path = relative(repoRoot, source); + if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) { throw new Error("path: must resolve inside --repo-root"); } - if (!statSync(source).isFile()) { + if (!statSync(source).isFile()) throw new Error("path: expected a regular file"); - } - return [relativePath, source]; + return { path: path.split(sep).join("/"), 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`); +function cweIds(value: unknown): string[] { + if (!Array.isArray(value)) throw new Error("cwe_ids: expected an array"); + const found = new Set(); + for (const item of value) { + const match = typeof item === "string" && /^CWE-(\d+)$/iu.exec(item.trim()); + if (!match || BigInt(match[1]!) < 1n) { + throw new Error(`cwe_ids: unsupported value ${JSON.stringify(item)}`); + } + found.add(BigInt(match[1]!)); } - return value; + return [...found] + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((number) => `CWE-${number}`); } -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 countLines(path: string): number { + const contents = readFileSync(path, "utf8"); + return contents === "" + ? 0 + : contents.split("\n").length - (contents.endsWith("\n") ? 1 : 0); +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; } function normalizeLocations( - row: JsonObject, + value: unknown, 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(":")) - ) { + const locations = new Map(); + for (const raw of value) { + const item = object(raw, LOCATION_FIELDS); + const { path, source } = repoFile(item["path"], repoRoot); + if (!path.trim() || /[\\:]/u.test(path)) { 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) { + const end = + item["end_line"] === undefined + ? start + : positiveLine(item["end_line"], "end_line"); + const lineCount = lineCounts.get(source) ?? countLines(source); + lineCounts.set(source, lineCount); + if (end < start || end > lineCount) { throw new Error( - `line range ${start}-${end} exceeds ${relativePath}:${lineCount}`, + `invalid line range ${start}-${end} for ${path} (${lineCount} lines)`, ); } - 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 resolveAllowMissing(value: string): string { - const absolute = - process.platform === "win32" - ? resolve(value) - : 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: (string | { symlink: string })[] = initialPath.parts; - let current = initialPath.root; - let index = 0; - const activeLinks = new Set(); - while (index < parts.length) { - const component = parts[index++]!; - if (typeof component !== "string") { - activeLinks.delete(component.symlink); - continue; - } - 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); - if (activeLinks.has(candidate)) { - throw new Error(`too many symbolic links while resolving ${value}`); - } - activeLinks.add(candidate); - current = isAbsolute(target) ? targetPath.root : dirname(candidate); - parts = [...targetPath.parts, { symlink: candidate }, ...remainder]; - index = 0; - } - return current; + const role = ROLES.find((role) => role === item["role"]); + if (role === undefined) throw new Error("role: unsupported value"); + const location = { path, start_line: start, end_line: end, role }; + locations.set(JSON.stringify(location), location); + } + return [...locations.values()].sort( + (left, right) => + ROLES.indexOf(left.role) - ROLES.indexOf(right.role) || + compareStrings(left.path, right.path) || + left.start_line - right.start_line || + left.end_line - right.end_line, + ); } -export function readScope( - scopePath: string, +function readScope( + path: string, repoRoot: string, - allowMissing = false, + allowMissing: boolean, ): Set { - const contents = new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: true, - }).decode(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); - } - } + for (const [index, line] of readLines(path).entries()) { if (!line) continue; try { - const [relativePath] = relativeFile(line, repoRoot); - scope.add(relativePath); + scope.add(repoFile(line, repoRoot).path); } 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; + if (!allowMissing || !isMissingFile(error)) { + throw new Error(`in-scope file row ${index + 1}: ${message(error)}`); } - 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, + value: unknown, 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); + const row = object(value, FIELDS); + const locations = normalizeLocations(row["locations"], 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), + cwe_ids: cweIds(row["cwe_ids"]), locations, - summary: textField(row, "summary")!, - evidence: textField(row, "evidence")!, + summary: text(row["summary"], "summary"), + evidence: text(row["evidence"], "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; + for (const field of ["context", "instance"] as const) { + if (row[field] != null) result[field] = text(row[field], field); + } return result; } -function identity(row: NormalizedCandidate): string { - return canonicalJson({ - cwe_ids: row.cwe_ids, - locations: row.locations, - instance: row.instance ?? null, - }); +function identity({ + cwe_ids, + locations, + instance, +}: NormalizedCandidate): string { + return JSON.stringify({ cwe_ids, locations, instance }); } function mergedText( - group: NormalizedCandidate[], + rows: 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"); + const values = rows + .map((row) => row[field]) + .filter((value) => value !== undefined); + return [...new Set(values)].sort().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 }); - // Keep a trailing carriage return until the next chunk so CRLF stays together. - const newline = /\r\n|\n|\r(?!$)/u; - let remainder = ""; - let number = 0; - for await (const chunk of createReadStream(source)) { - remainder += decoder.decode(chunk as Buffer, { stream: true }); - let boundary = newline.exec(remainder); - while (boundary !== null) { - const end = boundary.index + boundary[0].length; - number += 1; - yield [number, remainder.slice(0, end)]; - remainder = remainder.slice(end); - boundary = newline.exec(remainder); - } - } - remainder += decoder.decode(); - if (remainder) yield [number + 1, remainder]; -} - -function expandUser(value: string): string { - if (!value.startsWith("~")) return value; - const boundary = value.search( - process.platform === "win32" ? /[\\/]/u : /\//u, - ); - const end = boundary === -1 ? value.length : boundary; - const userName = value.slice(1, end); - let userDirectory = homedir(); - if (userName && process.platform === "win32") { - const currentUser = process.env["USERNAME"]; - if (userName !== currentUser) { - if (basename(userDirectory) !== currentUser) { - throw new Error("Could not determine home directory."); - } - userDirectory = join(dirname(userDirectory), userName); - } - } else if (userName) { - const account = - process.platform === "darwin" - ? execFileSync("dscacheutil", ["-q", "user", "-a", "name", userName], { - encoding: "utf8", - }) - : execFileSync("getent", ["passwd", userName], { encoding: "utf8" }); - const directory = - process.platform === "darwin" - ? /^dir: (.*)$/mu.exec(account)?.[1] - : account.trimEnd().split(":")[5]; - if (directory === undefined) - throw new Error("Could not determine home directory."); - userDirectory = directory; - } - return userDirectory + value.slice(end); -} - -function isArgumentValue(value: string): boolean { - return ( - !value.startsWith("-") || - value === "-" || - /^-(?:\p{Decimal_Number}+|\p{Decimal_Number}*\.\p{Decimal_Number}+)$/u.test( - value, - ) - ); -} - -function resolveLongOption(argument: string): { - attachedValue?: string; - option: string; -} { - if (argument === "-h") return { option: "--help" }; - 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 parseArguments(argv: string[]): CliArguments | undefined { - let inputs: string[] | undefined; - let output: string | undefined; - let repoRoot: string | undefined; - let scopePath: string | undefined; - let allowMissingInScope = false; - const unrecognized: string[] = []; - const takeValue = ( - index: number, - option: string, - attachedValue: string | undefined, - ): string => { - if (attachedValue !== undefined) return attachedValue; - const value = argv[index + 1]; - if (value === undefined || !isArgumentValue(value)) { - 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 ( - attachedValue === undefined && - argv[index + 1] !== undefined && - isArgumentValue(argv[index + 1]!) - ) { - 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" || option === "--help") { - if (attachedValue !== undefined) { - throw new Error(`${option}: does not take a value`); - } - if (option === "--help") return undefined; - allowMissingInScope = true; - } else { - unrecognized.push(argument); + return [...Map.groupBy(rows, identity)] + .sort(([left], [right]) => compareStrings(left, right)) + .map(([key, group]) => { + const { cwe_ids, locations, instance } = group[0]!; + return { + candidate_id: `candidate-${createHash("sha256").update(key).digest("hex").slice(0, 16)}`, + cwe_ids, + locations, + summary: mergedText(group, "summary"), + evidence: mergedText(group, "evidence"), + context: mergedText(group, "context") || undefined, + instance, + }; + }); +} + +function writeCombined( + output: string, + rows: CombinedCandidate[], + inputs: string[], +): void { + try { + const target = realpathSync.native(output); + if (inputs.some((input) => relative(input, target) === "")) { + throw new Error("--out: must not replace an input or the scope file"); } + } catch (error) { + if (!isMissingFile(error)) throw error; } - if (unrecognized.length > 0) - throw new Error(`unrecognized arguments ${unrecognized.join(" ")}`); - 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 }); - const directory = mkdtempSync( - join(dirname(output), `.${parse(output).base}.`), - ); - const temporary = join(directory, "output"); + const directory = mkdtempSync(join(dirname(output), ".normalize-")); + const temporary = join(directory, "candidates.jsonl"); try { - const descriptor = openSync(temporary, "wx", 0o600); - try { - for (const row of rows) { - writeFileSync(descriptor, `${canonicalJson(row)}${EOL}`, { - encoding: "utf8", - }); - } - } finally { - closeSync(descriptor); - } + writeFileSync( + temporary, + rows.map((row) => `${JSON.stringify(row)}\n`).join(""), + { mode: 0o600 }, + ); renameSync(temporary, output); } finally { rmSync(directory, { recursive: true, force: true }); } } -async function normalizeCandidates( - args: CliArguments, -): Promise<[number, number, string]> { - const repoRoot = realpathSync.native(expandUser(args.repoRoot)); - if (!statSync(repoRoot).isDirectory()) { - throw new Error("--repo-root: expected a directory"); +const HELP = `Validate and combine security-scan candidates into deterministic JSONL. + +Usage: normalize_candidates.mjs --input [--input ...] --out --repo-root --in-scope-files [--allow-missing-in-scope] + +Input and scope files use LF or CRLF lines. Missing scope entries are skipped when allowed. +Use full option names. The invoking shell expands home paths.`; + +function main(argv: string[]): void { + const { values } = parseArgs({ + args: argv, + options: { + input: { type: "string", multiple: true }, + out: { type: "string" }, + "repo-root": { type: "string" }, + "in-scope-files": { type: "string" }, + "allow-missing-in-scope": { type: "boolean", default: false }, + help: { type: "boolean", short: "h" }, + }, + }); + if (values.help) { + console.log(HELP); + return; + } + const { + input: inputFiles, + out, + "repo-root": root, + "in-scope-files": scopeFile, + } = values; + if ( + !inputFiles?.length || + out === undefined || + root === undefined || + scopeFile === undefined + ) { + throw new Error( + "--input, --out, --repo-root and --in-scope-files are required", + ); } - const output = resolveAllowMissing(expandUser(args.output)); - const scopePath = realpathSync.native(expandUser(args.scopePath)); + const repoRoot = realpathSync.native(resolve(root)); + if (!statSync(repoRoot).isDirectory()) + throw new Error("--repo-root: expected a directory"); + const scopePath = realpathSync.native(resolve(scopeFile)); const inputs = [ - ...new Set( - args.inputs.map((value) => realpathSync.native(expandUser(value))), - ), - ].sort(comparePythonStrings); - if (inputs.some((input) => relative(input, output) === "")) - throw new Error("--out: must not also be an input"); - if (relative(scopePath, output) === "") { - throw new Error("--out: must not replace --in-scope-files"); - } - const scope = readScope(scopePath, repoRoot, args.allowMissingInScope); + ...new Set(inputFiles.map((path) => realpathSync.native(resolve(path)))), + ]; + const output = resolve(out); + const scope = readScope( + scopePath, + repoRoot, + values["allow-missing-in-scope"], + ); const lineCounts = new Map(); const rows: NormalizedCandidate[] = []; for (const source of inputs) { - for await (const [number, line] of lines(source)) { - if (!pythonStrip(line)) continue; + for (const [index, line] of readLines(source).entries()) { + if (!line.trim()) continue; try { - const value: unknown = JSON.parse( - line, - (key, value: unknown, context?: { source?: string }) => { - if ( - (key === "start_line" || key === "end_line") && - typeof value === "number" && - /[.eE]/u.test(context?.source ?? "") - ) { - throw new Error(`${key}: expected a positive integer`); - } - return value; - }, + rows.push( + normalizeCandidate(JSON.parse(line), repoRoot, scope, lineCounts), ); - 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}`); + throw new Error(`${source} row ${index + 1}: ${message(error)}`); } } } 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 { - try { - const args = parseArguments(process.argv.slice(2)); - if (args === undefined) { - process.stdout.write(`${HELP.replaceAll("\n", EOL)}${EOL}`); - return; - } - const [rows, combined, output] = await normalizeCandidates(args); - process.stdout.write( - `Combined ${rows} candidate rows into ${combined} rows in ${output}${EOL}`, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`normalize_candidates: ${message}${EOL}`); - process.exitCode = 2; - } + writeCombined(output, combined, [...inputs, scopePath]); + console.log( + `Combined ${rows.length} candidate rows into ${combined.length} rows in ${output}`, + ); } const entrypoint = process.argv[1]; if ( entrypoint !== undefined && - realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entrypoint) + realpathSync.native(fileURLToPath(import.meta.url)) === + realpathSync.native(entrypoint) ) { - await runCli(); + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`normalize_candidates: ${message(error)}`); + process.exitCode = 2; + } } diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 2b972c920..34af40030 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -79,26 +79,28 @@ 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 +### TypeScript candidate normalizer -While a bundled helper is being migrated, keep the Python implementation as -the executable oracle and run both implementations against the same fixtures. - -The normalizer source lives in `plugins/codex-security/scripts/normalize_candidates.ts`. -`build:plugin` compiles it into the ignored `_bundled_plugin` payload alongside -the Python helper. The differential command rebuilds that payload first. -Named-user home paths use OS account lookup: `dscacheutil` on macOS and -`getent` on Linux. +The prototype lives in `plugins/codex-security/scripts/normalize_candidates.ts`. +`build:plugin` compiles it into the ignored `_bundled_plugin` payload. +Production callers still use the unchanged Python helper. ```sh -pnpm run test:normalizer-differential -CODEX_SECURITY_PROPERTY_RUNS=1000 bun test --timeout 900000 tests-ts/normalize-candidates.property.test.ts +pnpm run build:plugin +bun test --timeout 30000 tests-ts/normalize-candidates.test.ts tests-ts/normalize-candidates-filesystem.test.ts 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. +The CLI uses Node's argument parser. Repeat `--input FILE` for each input, use +full option names, and let the shell expand home paths. Paths follow Node's +normal resolution rules. JSONL and scope files use LF or CRLF lines; +`--allow-missing-in-scope` skips missing entries. Output uses `JSON.stringify` +and LF line endings, and atomic replacement replaces an output symlink rather +than modifying its target. + +Candidate IDs use a fixed TypeScript object shape and may differ from the +production helper. The tests cover normalization, deterministic output, scan +boundaries, and atomic writes. Run them on Linux, macOS, and Windows before +changing the production entrypoint. ## GitHub Actions diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 1ff9197dd..0ac005273 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -59,8 +59,7 @@ "test:mcp": "node --run build:plugin && pnpm --dir ../../plugins/codex-security/mcp-app run test:mcp", "test:mutation": "stryker run", "test:package": "node scripts/smoke-package.mjs", - "types": "pnpm run generate:models:check && pnpm --dir ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit", - "test:normalizer-differential": "node --run build:plugin && bun test --timeout 30000 tests-ts/normalize-candidates.test.ts tests-ts/normalize-candidates-filesystem.test.ts tests-ts/normalize-candidates.property.test.ts" + "types": "pnpm run generate:models:check && pnpm --dir ../../plugins/codex-security/mcp-app run typecheck && tsc --noEmit" }, "dependencies": { "@inquirer/prompts": "8.3.0", diff --git a/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts index ac53131ce..a7afe3f2f 100644 --- a/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts +++ b/sdk/typescript/tests-ts/normalize-candidates-filesystem.test.ts @@ -11,14 +11,13 @@ import { symlinkSync, writeFileSync, } from "node:fs"; -import { tmpdir, userInfo } from "node:os"; -import { join, relative, sep } from "node:path"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; import { normalizerArguments, - runPythonNormalizer, - runTypeScriptNormalizer, + runNormalizer, writeSource, } from "./support/normalize-candidates.js"; @@ -33,20 +32,7 @@ afterEach(() => { } }); -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 { +function candidate(path = "src/in-scope.ts") { return { cwe_ids: ["CWE-79"], locations: [{ path, start_line: 1, role: "source" }], @@ -55,195 +41,39 @@ function candidate(path = "src/in-scope.ts"): Record { }; } -function writeCandidate(root: string, row = candidate()): string { +function fixture() { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-normalizer-filesystem-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + writeSource(repository, "src/in-scope.ts", "one\ntwo\n"); + const inventory = join(root, "in-scope.txt"); const input = join(root, "candidates.jsonl"); - writeFileSync(input, `${JSON.stringify(row)}\n`); - return input; + const output = join(root, "output.jsonl"); + writeFileSync(inventory, "src/in-scope.ts\n"); + writeFileSync(input, `${JSON.stringify(candidate())}\n`); + const args = normalizerArguments([input], output, repository, inventory); + return { root, repository, inventory, input, output, args }; } -describe("candidate normalizer filesystem parity", () => { - testPosix( - "resolves CLI paths after directory links and parent components", - () => { - const { repository, root } = fixture(); - writeCandidate(repository); - writeFileSync(join(repository, "in-scope.txt"), "src/in-scope.ts\n"); - const nested = join(repository, "nested"); - mkdirSync(nested); - const linked = join(root, "linked"); - symlinkSync(nested, linked, "dir"); - const outputs: Buffer[] = []; - for (const [name, run] of [ - ["python", runPythonNormalizer], - ["typescript", runTypeScriptNormalizer], - ] as const) { - const output = join(root, `${name}.jsonl`); - const result = run( - normalizerArguments( - [`${linked}/../candidates.jsonl`], - output, - `${linked}/..`, - `${linked}/../in-scope.txt`, - ), - ); - expect(result.status, result.stderr).toBe(0); - outputs.push(readFileSync(output)); - } - expect(outputs[1]).toEqual(outputs[0]); - }, - ); - - testPosix( - "rejects expanding symlink loops and permits repeated non-cyclic links", - () => { - const { inventory, repository, root } = fixture(); - const input = writeCandidate(root); - const loop = join(root, "loop"); - symlinkSync("loop/child", loop, "dir"); - const repeated = join(root, "repeated"); - symlinkSync(root, repeated, "dir"); - const outputs: Buffer[] = []; - for (const [name, run] of [ - ["python", runPythonNormalizer], - ["typescript", runTypeScriptNormalizer], - ] as const) { - const invalid = run( - normalizerArguments( - [input], - join(loop, `${name}.jsonl`), - repository, - inventory, - ), - undefined, - { timeout: 5_000 }, - ); - expect(invalid.error).toBeUndefined(); - expect(invalid.status, invalid.stderr).toBe(2); - expect(invalid.stdout).toBe(""); - const result = run( - normalizerArguments( - [input], - join(repeated, "repeated", `${name}.jsonl`), - repository, - inventory, - ), - ); - expect(result.status, result.stderr).toBe(0); - outputs.push(readFileSync(join(root, `${name}.jsonl`))); - } - expect(outputs[1]).toEqual(outputs[0]); - }, - ); - - test("expands named-user home paths before resolving CLI paths", () => { - const { inventory, repository, root } = fixture(); - const input = writeCandidate(root); - const user = userInfo(); - const userName = - process.platform === "win32" ? "named-user" : user.username; - const userDirectory = - process.platform === "win32" - ? join(root, "profiles", userName) - : user.homedir; - const environment = - process.platform === "win32" - ? { - USERNAME: "current-user", - USERPROFILE: join(root, "profiles", "current-user"), - } - : undefined; - if (environment !== undefined) { - mkdirSync(userDirectory, { recursive: true }); - mkdirSync(environment.USERPROFILE, { recursive: true }); - } - const namedPath = (path: string) => - `~${userName}${sep}${relative(userDirectory, path)}`; - const outputs: Buffer[] = []; - for (const [name, run] of [ - ["python", runPythonNormalizer], - ["typescript", runTypeScriptNormalizer], - ] as const) { - const output = join(root, `${name}.jsonl`); - const result = run( - normalizerArguments( - [namedPath(input)], - namedPath(output), - namedPath(repository), - namedPath(inventory), - ), - undefined, - { env: environment }, - ); - expect(result.status, result.stderr).toBe(0); - outputs.push(readFileSync(output)); - } - expect(outputs[1]).toEqual(outputs[0]); - }); - - test.each(["1.0", "1e0"])( - "rejects floating line tokens %s without replacing output", - (token) => { - const { inventory, repository, root } = fixture(); - const input = join(root, "candidates.jsonl"); - for (const field of ["start_line", "end_line"]) { - const row = { - ...candidate(), - locations: [ - { - path: "src/in-scope.ts", - start_line: 1, - end_line: 1, - role: "source", - }, - ], - }; - writeFileSync( - input, - JSON.stringify(row).replace(`"${field}":1`, `"${field}":${token}`) + - "\n", - ); - for (const [name, run] of [ - ["python", runPythonNormalizer], - ["typescript", runTypeScriptNormalizer], - ] as const) { - const output = join(root, `${name}.jsonl`); - writeFileSync(output, "existing output\n"); - const result = run( - normalizerArguments([input], output, repository, inventory), - ); - expect(result.status, `${name} ${field}=${token}`).toBe(2); - expect(result.stderr).toContain( - `${field}: expected a positive integer`, - ); - expect(readFileSync(output, "utf8")).toBe("existing output\n"); - } - } - }, - ); - +describe("candidate normalizer filesystem contract", () => { test("runs through a linked helper directory", () => { - const { inventory, repository, root } = fixture(); - const input = writeCandidate(root); + const { root, output, args } = fixture(); const linked = join(root, "linked-helpers"); symlinkSync(join(PLUGIN_ROOT, "scripts"), linked, directoryLinkType); - const outputs: Buffer[] = []; - for (const [name, script, run] of [ - ["python", "normalize_candidates.py", runPythonNormalizer], - ["typescript", "normalize_candidates.mjs", runTypeScriptNormalizer], - ] as const) { - const output = join(root, `${name}.jsonl`); - const result = run( - normalizerArguments([input], output, repository, inventory), - join(linked, script), - ); - expect(result.status, result.stderr).toBe(0); - outputs.push(readFileSync(output)); - } - expect(outputs[1]).toEqual(outputs[0]); + const result = runNormalizer( + args, + join(linked, "normalize_candidates.mjs"), + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8")).summary).toBe( + "Synthetic candidate", + ); }); - test("canonicalizes in-repository directory links and preserves hard-link names", () => { - const { inventory, repository, root } = fixture(); + test("canonicalizes directory links and preserves hard-link names", () => { + const { inventory, repository, input, output, args } = fixture(); writeSource(repository, "real/target.ts", "target\n"); mkdirSync(join(repository, "aliases")); linkSync( @@ -256,272 +86,188 @@ describe("candidate normalizer filesystem parity", () => { 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), + writeFileSync( + input, + JSON.stringify({ + ...candidate(), + locations: [ + { path: "linked/target.ts", start_line: 1, role: "sink" }, + { path: "aliases/hard.ts", start_line: 1, role: "source" }, + ], + }) + "\n", ); - 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"'); + const result = runNormalizer(args); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8")).locations).toMatchObject([ + { path: "aliases/hard.ts" }, + { path: "real/target.ts" }, + ]); }); testPosix( "rejects directories, broken links, and FIFOs without changing output", () => { - const { inventory, repository, root } = fixture(); + const { repository, input, output, args } = fixture(); mkdirSync(join(repository, "src", "directory")); symlinkSync("missing.ts", join(repository, "src", "broken.ts")); + symlinkSync("loop.ts", join(repository, "src", "loop.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 [ + const result = spawnSync("mkfifo", [fifo], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + writeFileSync(output, "existing output\n"); + for (const path of [ "src/directory", "src/broken.ts", + "src/loop.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( + runNormalizer(args, undefined, { timeout: 30_000 }).status, + path, + ).toBe(2); + expect(readFileSync(output, "utf8")).toBe("existing output\n"); + } + }, + ); - 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.each(["scope", "candidate"])( + "rejects invalid UTF-8 in %s without changing output", + (kind) => { + const { repository, inventory, input, output, args } = fixture(); + if (kind === "scope") { + writeSource(repository, "src/�.ts", "one\n"); + writeFileSync(input, JSON.stringify(candidate("src/�.ts")) + "\n"); + writeFileSync( + inventory, + Buffer.concat([ + Buffer.from("src/"), + Buffer.from([0xff]), + Buffer.from(".ts\n"), + ]), ); + } else { + const contents = readFileSync(input); + contents[contents.indexOf("Synthetic candidate")] = 0xff; + writeFileSync(input, contents); } + writeFileSync(output, "existing output\n"); + expect(runNormalizer(args).status).toBe(2); + expect(readFileSync(output, "utf8")).toBe("existing output\n"); }, ); - 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, - ), - ); + test.each(["{", "null", "[]"])( + "rejects invalid candidate document %s without changing output", + (contents) => { + const { input, output, args } = fixture(); + writeFileSync(input, `${contents}\n`); + writeFileSync(output, "existing output\n"); + const result = runNormalizer(args); + expect(result.status, result.stderr).toBe(2); + expect(result.stderr).toContain("row 1"); + expect(readFileSync(output, "utf8")).toBe("existing output\n"); + }, + ); - 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, - ); - } + test("skips missing scope entries without admitting files outside the repository", () => { + const { root, repository, inventory, input, output, args } = fixture(); + const outside = join(root, "outside"); + mkdirSync(outside); + symlinkSync(outside, join(repository, "linked"), directoryLinkType); + writeFileSync(inventory, "src/in-scope.ts\nlinked/deleted.ts\n"); + expect(runNormalizer(args).status).toBe(2); + const allowed = [...args, "--allow-missing-in-scope"]; + const result = runNormalizer(allowed); + expect(result.status, result.stderr).toBe(0); + const before = readFileSync(output); + writeFileSync(join(outside, "deleted.ts"), "outside\n"); + expect(runNormalizer(allowed).status).toBe(2); + expect(readFileSync(output)).toEqual(before); + writeFileSync(inventory, "src/in-scope.ts\n"); + writeFileSync(input, JSON.stringify(candidate("linked/deleted.ts")) + "\n"); + expect(runNormalizer(allowed).status).toBe(2); + expect(readFileSync(output)).toEqual(before); }); - 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", + testPosix("replaces an output symlink without changing its target", () => { + const { root, output, args } = fixture(); + const target = join(root, "unrelated.txt"); + writeFileSync(target, "leave this alone\n"); + symlinkSync(target, output); + const result = runNormalizer(args); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(target, "utf8")).toBe("leave this alone\n"); + expect(JSON.parse(readFileSync(output, "utf8")).summary).toBe( + "Synthetic candidate", ); }); 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( + const { inventory, repository, input } = fixture(); + for (const output of [input, inventory]) { + const before = readFileSync(output); + const result = runNormalizer( 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); + expect(result.status, result.stderr).toBe(2); + expect(readFileSync(output)).toEqual(before); } }); - 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, - }); + test("writes private files atomically and cleans failed write temporaries", () => { + const { root, output, args } = fixture(); + writeFileSync(output, "existing output\n", { mode: 0o644 }); const before = readdirSync(root).sort(); - - const pythonResult = runPythonNormalizer( - normalizerArguments([input], pythonOutput, repository, inventory), + const result = runNormalizer(args); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8")).summary).toBe( + "Synthetic candidate", ); - 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); - } + if (process.platform !== "win32") + expect(statSync(output).mode & 0o777).toBe(0o600); expect(readdirSync(root).sort()).toEqual(before); - const blockedPythonOutput = join(root, "blocked-python.jsonl"); - const blockedTypeScriptOutput = join(root, "blocked-typescript.jsonl"); - mkdirSync(blockedPythonOutput); - mkdirSync(blockedTypeScriptOutput); - const beforeFailure = readdirSync(root).sort(); - 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(readdirSync(root).sort()).toEqual(beforeFailure); + rmSync(output); + mkdirSync(output); + expect(runNormalizer(args).status).toBe(2); + expect(statSync(output).isDirectory()).toBe(true); + expect(readdirSync(root).sort()).toEqual(before); }); - 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); - }); + testWindows( + "accepts Windows separators and rejects absolute drive paths", + () => { + const { inventory, input, output, args } = fixture(); + writeFileSync(inventory, "src\\in-scope.ts\r\n"); + writeFileSync( + input, + JSON.stringify(candidate("src\\in-scope.ts")) + "\n", + ); + const result = runNormalizer(args); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, "utf8")).locations[0].path).toBe( + "src/in-scope.ts", + ); + writeFileSync(input, JSON.stringify(candidate("C:\\outside.ts")) + "\n"); + expect(runNormalizer(args).status).toBe(2); + }, + ); testWindows("protects differently cased input and scope paths", () => { - const { inventory, repository, root } = fixture(); - const input = writeCandidate(root); + const { inventory, repository, input } = fixture(); for (const output of [input, inventory]) { const before = readFileSync(output); - for (const run of [runPythonNormalizer, runTypeScriptNormalizer]) { - const result = run( - normalizerArguments( - [input], - output.toUpperCase(), - repository, - inventory, - ), - ); - expect(result.status, result.stderr).toBe(2); - expect(readFileSync(output)).toEqual(before); - } + const result = runNormalizer( + normalizerArguments( + [input], + output.toUpperCase(), + repository, + inventory, + ), + ); + expect(result.status, result.stderr).toBe(2); + expect(readFileSync(output)).toEqual(before); } }); }); diff --git a/sdk/typescript/tests-ts/normalize-candidates.property.test.ts b/sdk/typescript/tests-ts/normalize-candidates.property.test.ts index eb5621108..8eba34e9f 100644 --- a/sdk/typescript/tests-ts/normalize-candidates.property.test.ts +++ b/sdk/typescript/tests-ts/normalize-candidates.property.test.ts @@ -1,698 +1,85 @@ -import { - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import fc from "fast-check"; import { - normalizerArguments, - runPythonNormalizer, - runTypeScriptNormalizer, - writeSource, -} from "./support/normalize-candidates.js"; + combine, + normalizeCandidate, +} from "../../../plugins/codex-security/scripts/normalize_candidates.js"; +import { 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; -} - -const INVALID_KINDS = [ - "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", -] as const; -type InvalidKind = (typeof INVALID_KINDS)[number]; -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, - }), - ), + .string({ unit: "binary", maxLength: 40 }) + .map((value) => ` ${value}x `); +const candidate = fc.record({ + cwe_ids: fc.array( + fc.integer({ min: 1, max: 1000 }).map((value) => `cwe-0${value}`), + { maxLength: 4 }, + ), + locations: fc.array( + fc.record({ + path: fc.constantFrom("src/alpha.ts", "src/é.ts", "src/😀.ts"), + start_line: fc.integer({ min: 1, max: 3 }), + role: fc.constantFrom("source", "sink", "evidence"), + }), + { minLength: 1, maxLength: 4 }, ), -); -const variant = fc.record({ - candidateId: fc.oneof(fc.constant(undefined), text), - context: optionalText, - evidence: text, summary: text, + evidence: text, + context: fc.option(text, { nil: undefined }), + instance: fc.option(text, { nil: undefined }), }); -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"; - const digits = Array.from( - ["0123456789", "٠١٢٣٤٥٦٧٨٩", "0123456789", "𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡"][ - (index + cweIndex) % 4 - ]!, - ); - const value = `${padding}${number}`.replace( - /\d/gu, - (digit) => digits[Number(digit)]!, - ); - return `${whitespace}${prefix}${value}${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( +test("normalization is deterministic across row, location, and CWE order and duplicates", () => { + const repository = 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 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, + const scope = new Set(["src/alpha.ts", "src/é.ts", "src/😀.ts"]); + for (const path of scope) writeSource(repository, path, "one\ntwo\nthree\n"); + const lineCounts = new Map(); + const normalize = (rows: unknown[]) => + combine( + rows.map((row) => normalizeCandidate(row, repository, scope, lineCounts)), ); - }); - - test("agrees with Python on arbitrary JSON documents", () => { + try { 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, - ); - }); + fc.array(candidate, { minLength: 1, maxLength: 10 }), + (rows) => { + const variants = rows.flatMap((row) => [ + row, + { ...row, context: undefined }, + ]); + const expected = normalize(variants); + const reordered = variants.toReversed().map((row) => ({ + ...row, + cwe_ids: [...row.cwe_ids.toReversed(), ...row.cwe_ids], + locations: [...row.locations.toReversed(), ...row.locations], + })); + expect(JSON.stringify(normalize([...reordered, ...reordered]))).toBe( + JSON.stringify(expected), + ); + expect(new Set(expected.map((row) => row.candidate_id)).size).toBe( + expected.length, + ); - 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 before = readdirSync(root).sort(); - 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(readdirSync(root).sort()).toEqual(before); - } finally { - rmSync(root, { recursive: true, force: true }); - } + const changedText = rows.map((row) => ({ + ...row, + candidate_id: "upstream-id", + summary: "Reworded summary", + evidence: "Additional evidence", + context: "Additional context", + })); + expect(normalize(changedText).map((row) => row.candidate_id)).toEqual( + expected.map((row) => row.candidate_id), + ); }, ), - { - ...filesystemPropertyOptions, - numRuns: filesystemPropertyOptions.numRuns + INVALID_KINDS.length, - examples: INVALID_KINDS.map( - (kind, index): [CandidateRow[], InvalidKind, Uint8Array] => [ - VALID_EXAMPLE_ROWS, - kind, - Uint8Array.of(index + 1), - ], - ), - }, + propertyOptions, ); - }); + } finally { + rmSync(repository, { recursive: true, force: true }); + } }); diff --git a/sdk/typescript/tests-ts/normalize-candidates.test.ts b/sdk/typescript/tests-ts/normalize-candidates.test.ts index 2c00a9568..28ed7a122 100644 --- a/sdk/typescript/tests-ts/normalize-candidates.test.ts +++ b/sdk/typescript/tests-ts/normalize-candidates.test.ts @@ -1,445 +1,200 @@ import { - mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, - symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, sep } from "node:path"; +import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { - normalizerArguments as argumentsFor, - runPythonNormalizer as runPython, - runTypeScriptNormalizer as runTypeScript, + combine, + normalizeCandidate, +} from "../../../plugins/codex-security/scripts/normalize_candidates.js"; +import { + normalizerArguments, + runNormalizer, writeSource, } from "./support/normalize-candidates.js"; const temporaryRoots: string[] = []; -const directoryLinkType = process.platform === "win32" ? "junction" : "dir"; +const location = { + path: "src/in-scope.ts", + start_line: 1, + role: "source", +} as const; +const candidate = { + cwe_ids: ["CWE-79"], + locations: [location], + summary: "Candidate", + evidence: "Evidence", +}; afterEach(() => { - for (const root of temporaryRoots.splice(0)) { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); - } }); -function fixture(): { root: string; repository: string } { +function fixture() { const root = realpathSync( mkdtempSync(join(tmpdir(), "codex-security-normalizer-")), ); temporaryRoots.push(root); const repository = join(root, "repository"); - mkdirSync(repository); + writeSource(repository, location.path, "one\ntwo\n"); + writeSource(repository, "src/out-of-scope.ts", "one\n"); return { root, repository }; } -describe("TypeScript candidate normalizer prototype", () => { - test.each(["\n", "\r\n", "\r"])( - "matches Python normalization with JSONL separator %j", - (inputNewline) => { +describe("candidate normalizer", () => { + test.each(["\n", "\r\n"])( + "normalizes and combines JSONL with %j line endings", + (newline) => { 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", "CWE-٨٩", "CWE-79", "CWE-𝟠𝟡"], - locations: sharedLocations.slice().reverse(), - summary: " Résumé: missing guard ", - evidence: "earlier evidence", - context: "first context", - instance: " route:a ", - })}${inputNewline}`, - ); + writeSource(repository, location.path, `one${newline}two${newline}`); + const inventory = join(root, "scope.txt"); + const first = join(root, "first.jsonl"); + const second = join(root, "second.jsonl"); + const output = join(root, "output.jsonl"); + writeFileSync(inventory, `${location.path}${newline}`); writeFileSync( - secondInput, + first, [ - "", - 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", + ...candidate, + candidate_id: "discarded-id", + cwe_ids: [" cwe-079 ", "CWE-89"], + summary: " A summary ", + locations: [location, location], instance: "route:a", }), JSON.stringify({ - cwe_ids: [], - locations: [ - { path: "src/alpha.ts", start_line: 2, role: "evidence" }, - ], - summary: "Independent candidate", - evidence: "Separate identity", + ...candidate, + summary: "Separate candidate", + instance: "route:b", }), - "", - ].join(inputNewline), + ].join(newline), ); - 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, - ), + writeFileSync( + second, + `${JSON.stringify({ ...candidate, cwe_ids: ["CWE-89", "CWE-79"], summary: "B summary", evidence: "More evidence", context: "Context", instance: "route:a" })}${newline}`, ); - const typescriptResult = runTypeScript( - argumentsFor( - [secondInput, firstInput], - typescriptOutput, + const result = runNormalizer( + normalizerArguments( + [second, first, first], + output, 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").replaceAll("\r\n", "\n")).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', - ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Combined 3 candidate rows into 2 rows"); + const rows = readFileSync(output, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)) as ReturnType; + expect(rows).toHaveLength(2); + const merged = rows.find((row) => row.instance === "route:a")!; + expect(merged).toEqual({ + candidate_id: expect.stringMatching(/^candidate-[a-f0-9]{16}$/u), + cwe_ids: ["CWE-79", "CWE-89"], + locations: [{ ...location, end_line: 1 }], + summary: "A summary\nB summary", + evidence: "Evidence\nMore evidence", + context: "Context", + instance: "route:a", + }); + expect( + rows.find((row) => row.instance === "route:b")?.candidate_id, + ).not.toBe(merged.candidate_id); }, ); - 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.each([ + { name: "empty locations", patch: { locations: [] } }, + { name: "blank summary", patch: { summary: " \t" } }, + { name: "blank evidence", patch: { evidence: "" } }, + { name: "invalid context", patch: { context: false } }, + { name: "invalid instance", patch: { instance: 1 } }, + { name: "invalid CWE", patch: { cwe_ids: ["CWE-0"] } }, + { + name: "invalid role", + patch: { locations: [{ ...location, role: "unknown" }] }, + }, + { + name: "boolean line", + patch: { locations: [{ ...location, start_line: true }] }, + }, + { + name: "fractional line", + patch: { locations: [{ ...location, start_line: 1.5 }] }, + }, + { + name: "reversed range", + patch: { locations: [{ ...location, start_line: 2, end_line: 1 }] }, + }, + { + name: "line beyond file", + patch: { locations: [{ ...location, start_line: 3 }] }, + }, + { name: "unknown field", patch: { unexpected: true } }, + { + name: "unknown location field", + patch: { locations: [{ ...location, extra: true }] }, + }, + { + name: "path traversal", + patch: { locations: [{ ...location, path: "../outside.ts" }] }, + }, + { + name: "out of scope", + patch: { locations: [{ ...location, path: "src/out-of-scope.ts" }] }, + }, + ])("rejects $name", ({ patch }) => { + const { repository } = fixture(); + expect(() => + normalizeCandidate( + { ...candidate, ...patch }, + repository, + new Set([location.path]), + new Map(), + ), + ).toThrow(); }); - 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", - ], - }, + test("accepts explicit values for paths containing spaces or leading dashes", () => { + const { root } = fixture(); + const input = "-candidate input.jsonl"; + writeFileSync(join(root, input), `${JSON.stringify(candidate)}\n`); + writeFileSync(join(root, "in scope.txt"), `${location.path}\n`); + const args = [ + `--input=${input}`, + "--out=output.jsonl", + "--repo-root=repository", + "--in-scope-files=in scope.txt", ]; - - 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"], - [ - `--input=${input}`, - input, - `--out=${join(root, "unbound-input.jsonl")}`, - `--repo-root=${repository}`, - `--in-scope-files=${inventory}`, - ], - ]) { - expect(runPython(args).status).toBe(2); - expect(runTypeScript(args).status).toBe(2); - } - }); - - test("validates option values before processing help", () => { - for (const args of [ - ["--help"], - ["-h"], - ["--he"], - ["--help", "--out"], - ["--unknown", "--help"], - ["--out", "--help"], - ["--input", "--help"], - ["--repo-root", "-h"], - ["--in-scope-files", "--help"], - ["--help=value"], - ]) { - const expected = runPython(args); - const actual = runTypeScript(args); - expect(actual.status, args.join(" ")).toBe(expected.status); - if (expected.status === 2) expect(actual.stdout).toBe(""); - } + const result = runNormalizer(args, undefined, { cwd: root }); + expect(result.status, result.stderr).toBe(0); + expect( + JSON.parse(readFileSync(join(root, "output.jsonl"), "utf8")).summary, + ).toBe("Candidate"); + expect( + runNormalizer([...args, input], undefined, { cwd: root }).status, + ).toBe(2); }); - test("accepts negative-number filenames for scalar and multi-value options", () => { - const { root } = fixture(); - const repository = join(root, "-3"); - writeSource(repository, "source.ts", "one\n"); - writeFileSync(join(root, "-2"), "source.ts\n"); - writeFileSync( - join(root, "-1"), - JSON.stringify({ - cwe_ids: ["CWE-79"], - locations: [{ path: "source.ts", start_line: 1, role: "source" }], - summary: "Synthetic candidate", - evidence: "Synthetic evidence", - }) + "\n", - ); - for (const [run, output] of [ - [runPython, "-4"], - [runTypeScript, "-5"], - ] as const) { - const result = run(argumentsFor(["-1"], output, "-3", "-2"), undefined, { - cwd: root, - }); + test("shows help and reports missing or unknown arguments", () => { + for (const flag of ["--help", "-h"]) { + const result = runNormalizer([flag]); expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Usage:"); } - expect(readFileSync(join(root, "-5"))).toEqual( - readFileSync(join(root, "-4")), - ); - for (const option of [ - "--input", - "--out", - "--repo-root", - "--in-scope-files", - ]) { - for (const value of ["-", "-.5", "-1.5", "-١"]) { - const args = [option, value, "--help"]; - expect(runPython(args).status).toBe(0); - expect(runTypeScript(args).status).toBe(0); - } + for (const args of [[], ["--out"], ["--input"], ["--unknown"]]) { + const result = runNormalizer(args); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toBe(""); } }); - - 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 outputParent = - process.platform === "win32" ? root : join(root, "output"); - const pythonOutput = join(outputParent, "python.jsonl"); - const typescriptOutput = join(outputParent, "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/support/normalize-candidates.ts b/sdk/typescript/tests-ts/support/normalize-candidates.ts index 93a521d48..cd01b2ebc 100644 --- a/sdk/typescript/tests-ts/support/normalize-candidates.ts +++ b/sdk/typescript/tests-ts/support/normalize-candidates.ts @@ -3,26 +3,13 @@ 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( +const normalizer = 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, @@ -33,28 +20,12 @@ export function writeSource( writeFileSync(output, contents); } -export function runPythonNormalizer( - args: string[], - script = pythonNormalizer, - options: Pick = {}, -) { - return spawnSync(executable(python, "Python"), ["-B", script, ...args], { - ...options, - encoding: "utf8", - env: { ...process.env, ...options.env, PYTHONDONTWRITEBYTECODE: "1" }, - }); -} - -export function runTypeScriptNormalizer( +export function runNormalizer( args: string[], - script = typescriptNormalizer, - options: Pick = {}, + script = normalizer, + options: Pick = {}, ) { - return spawnSync(executable(node, "Node.js"), [script, ...args], { - ...options, - encoding: "utf8", - env: { ...process.env, ...options.env }, - }); + return spawnSync("node", [script, ...args], { ...options, encoding: "utf8" }); } export function normalizerArguments( @@ -65,8 +36,7 @@ export function normalizerArguments( allowMissing = false, ): string[] { return [ - "--input", - ...inputs, + ...inputs.flatMap((input) => ["--input", input]), "--out", output, "--repo-root", From 60ee8adc05318c9bcd281cb6d8d44cd06cb109b4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 07:49:53 -0700 Subject: [PATCH 5/8] refactor(plugin): replace Python candidate normalization --- .../codex-security/.codex-plugin/plugin.json | 2 +- .../mcp-app/src/artifact-discovery.ts | 16 +- .../mcp-app/tests/test_artifact_discovery.mjs | 7 +- .../tests/test_compact_artifact_server.mjs | 7 +- plugins/codex-security/plugin-files.json | 1 - .../scripts/normalize_candidates.py | 338 -------- .../skills/security-diff-scan/SKILL.md | 2 +- .../tests/test_normalize_candidates.py | 789 ------------------ sdk/typescript/TESTING.md | 11 +- sdk/typescript/src/version.ts | 2 +- .../tests-ts/compact-diff-scan.test.ts | 15 +- sdk/typescript/tests-ts/runtime.test.ts | 166 +--- 12 files changed, 25 insertions(+), 1331 deletions(-) delete mode 100644 plugins/codex-security/scripts/normalize_candidates.py delete mode 100644 plugins/codex-security/tests/test_normalize_candidates.py diff --git a/plugins/codex-security/.codex-plugin/plugin.json b/plugins/codex-security/.codex-plugin/plugin.json index 1261106d3..72eaf506a 100644 --- a/plugins/codex-security/.codex-plugin/plugin.json +++ b/plugins/codex-security/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.80", + "version": "0.1.79", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/plugins/codex-security/mcp-app/src/artifact-discovery.ts b/plugins/codex-security/mcp-app/src/artifact-discovery.ts index 622a8156e..09ee3303a 100644 --- a/plugins/codex-security/mcp-app/src/artifact-discovery.ts +++ b/plugins/codex-security/mcp-app/src/artifact-discovery.ts @@ -17,7 +17,6 @@ import { type SchemaDocument } from "./artifact-schema-loader.js"; import { candidateSchemaV1 } from "./deep-scan/artifact-contracts.js"; -import { missingPythonHelperMessage, resolvePythonCommand } from "./python_command.js"; const execFileAsync = promisify(execFile); const discoveryComponents = ["artifacts", "02_discovery"] as const; @@ -137,7 +136,7 @@ export async function recordCodexSecurityDiscoveryCandidates( const inventoryComponents = [...discoveryComponents, "in_scope_files.txt"]; const candidateComponents = [...discoveryComponents, "candidate_ledger.jsonl"]; - // Verify the inventory is a context-bound regular file before passing it to Python. + // Verify the inventory is a context-bound regular file before normalization. await readArtifactText(context, inventoryComponents, "discovery review inventory"); const inventoryPath = await artifactDestination( context, @@ -161,12 +160,11 @@ export async function recordCodexSecurityDiscoveryCandidates( mode: 0o600 }); - const pythonCommand = context.pythonCommand ?? await resolvePythonCommand(); try { await execFileAsync( - pythonCommand, + process.execPath, [ - join(pluginRoot, "scripts", "normalize_candidates.py"), + join(pluginRoot, "scripts", "normalize_candidates.mjs"), "--input", temporaryInput, "--out", @@ -184,7 +182,7 @@ export async function recordCodexSecurityDiscoveryCandidates( } ); } catch (error) { - throw discoveryNormalizationError(error, pythonCommand, [ + throw discoveryNormalizationError(error, [ [temporaryInput, "candidate input"], [temporaryDirectory, "private candidate input"], [inventoryPath, "the assigned review inventory"], @@ -226,14 +224,8 @@ export async function listCodexSecurityCandidates( function discoveryNormalizationError( error: unknown, - pythonCommand: string, privateValues: Array ): Error { - const pythonMessage = missingPythonHelperMessage(error, pythonCommand); - if (pythonMessage) { - return new Error(`${discoveryLabel}: ${pythonMessage}`, { cause: error }); - } - const stderr = error && typeof error === "object" && "stderr" in error ? error.stderr : undefined; diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_discovery.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_discovery.mjs index b1c6753f6..0049024ed 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_discovery.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_discovery.mjs @@ -34,7 +34,9 @@ const { `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); -const pluginRoot = fileURLToPath(new URL("../../", import.meta.url)); +const pluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT + ? path.resolve(process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT) + : fileURLToPath(new URL("../../../../sdk/typescript/_bundled_plugin/", import.meta.url)); const definitions = JSON.parse(await readFile(path.join( pluginRoot, "schemas", @@ -388,7 +390,8 @@ async function createContext(root, repoRoot, name, layout) { root: artifactRoot, repoRoot, layout, - pluginRoot + pluginRoot, + pythonCommand: path.join(root, "missing-python") }; } diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index d1af7e3da..f3f9e48f7 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -13,8 +13,7 @@ const applicationRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), ".." ); -const pluginRoot = path.resolve(applicationRoot, ".."); -const bundledPluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT +const pluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT ? path.resolve(process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT) : path.resolve(applicationRoot, "../../../sdk/typescript/_bundled_plugin"); const temporaryRoot = await mkdtemp(path.join(tmpdir(), "codex-security-artifact-mcp-")); @@ -30,7 +29,7 @@ try { await testDiscoveryWorkerToolList(runtimeBundle); await testReducerWorkerToolList(runtimeBundle); - const shippedRuntime = path.join(bundledPluginRoot, "mcp", "server.mjs"); + const shippedRuntime = path.join(pluginRoot, "mcp", "server.mjs"); await testParentToolList(shippedRuntime); await testClaimedParentArtifactOperations(shippedRuntime, "shipped"); await testSemanticScanDraftCompletion(shippedRuntime, "shipped"); @@ -1179,7 +1178,7 @@ async function bundleEntrypoint(entrypoint, outfile) { await build({ bundle: true, define: { - __dirname: JSON.stringify(applicationRoot), + __dirname: JSON.stringify(path.join(pluginRoot, "mcp")), "import.meta.url": "__filename" }, entryPoints: [path.join(applicationRoot, entrypoint)], diff --git a/plugins/codex-security/plugin-files.json b/plugins/codex-security/plugin-files.json index 7b00e9a38..bf6885699 100644 --- a/plugins/codex-security/plugin-files.json +++ b/plugins/codex-security/plugin-files.json @@ -51,7 +51,6 @@ "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", "scripts/resolve_security_md.py", diff --git a/plugins/codex-security/scripts/normalize_candidates.py b/plugins/codex-security/scripts/normalize_candidates.py deleted file mode 100644 index a445acd70..000000000 --- a/plugins/codex-security/scripts/normalize_candidates.py +++ /dev/null @@ -1,338 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and combine security-scan candidates into deterministic JSONL.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import sys -import tempfile -from pathlib import Path, PurePosixPath -from typing import Any - -CWE = re.compile(r"(?i)CWE-(\d+)") -ROLES = { - "entrypoint": 0, - "entrypoint/wrapper": 1, - "source": 2, - "root_control": 3, - "sink": 4, - "concrete_implementation": 5, - "evidence": 6, -} -FIELDS = { - "candidate_id", - "cwe_ids", - "locations", - "summary", - "evidence", - "context", - "instance", -} - - -def text_field(row: dict[str, Any], field: str, *, required: bool = True) -> str | None: - value = row.get(field) - if value is None and not required: - return None - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field}: expected a non-empty string") - return value.strip() - - -def cwe_ids(row: dict[str, Any]) -> list[str]: - value = row.get("cwe_ids") - if not isinstance(value, list): - raise ValueError("cwe_ids: expected an array") - found: set[int] = set() - for item in value: - if not isinstance(item, str): - raise ValueError("cwe_ids: expected CWE strings") - match = CWE.fullmatch(item.strip()) - if match is None or int(match[1]) < 1: - raise ValueError(f"cwe_ids: unsupported value {item!r}") - found.add(int(match[1])) - return [f"CWE-{number}" for number in sorted(found)] - - -def relative_file(value: Any, repo_root: Path) -> tuple[str, Path]: - if not isinstance(value, str) or not value or "\0" in value: - raise ValueError("path: expected a non-empty repository-relative path") - raw = value - if sys.platform == "win32": - raw = raw.replace("\\", "/") - path = PurePosixPath(raw) - if ( - path.is_absolute() - or ".." in path.parts - or (sys.platform == "win32" and re.match(r"^[A-Za-z]:", raw)) - ): - raise ValueError("path: expected a repository-relative path without traversal") - resolved = (repo_root / raw).resolve(strict=True) - try: - relative = resolved.relative_to(repo_root).as_posix() - except ValueError as error: - raise ValueError("path: must resolve inside --repo-root") from error - if not resolved.is_file(): - raise ValueError("path: expected a regular file") - return relative, resolved - - -def positive_line(value: Any, field: str) -> int: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise ValueError(f"{field}: expected a positive integer") - return value - - -def normalize_locations( - row: dict[str, Any], repo_root: Path, line_counts: dict[Path, int] -) -> list[dict[str, Any]]: - value = row.get("locations") - if not isinstance(value, list) or not value: - raise ValueError("locations: expected a non-empty array") - normalized: dict[tuple[str, int, int, str], dict[str, Any]] = {} - for item in value: - if not isinstance(item, dict): - raise ValueError("locations: expected location objects") - unknown = set(item) - {"path", "start_line", "end_line", "role"} - if unknown: - raise ValueError(f"locations: unsupported fields {', '.join(sorted(unknown))}") - relative, source = relative_file(item.get("path"), repo_root) - if ( - not relative.strip() - or "\\" in relative - or any(":" in part for part in PurePosixPath(relative).parts) - ): - raise ValueError("path: expected a safe repository-relative POSIX path") - start = positive_line(item.get("start_line"), "start_line") - end = positive_line(item.get("end_line", start), "end_line") - if end < start: - raise ValueError("end_line: must be greater than or equal to start_line") - if source not in line_counts: - line_counts[source] = len(source.read_bytes().splitlines()) - if end > line_counts[source]: - raise ValueError(f"line range {start}-{end} exceeds {relative}:{line_counts[source]}") - role = item.get("role") - if not isinstance(role, str) or role not in ROLES: - raise ValueError(f"role: unsupported value {role!r}") - key = (relative, start, end, role) - normalized[key] = { - "path": relative, - "start_line": start, - "end_line": end, - "role": role, - } - return sorted( - normalized.values(), - key=lambda item: ( - ROLES[item["role"]], - item["path"], - item["start_line"], - item["end_line"], - ), - ) - - -def read_scope(path: Path, repo_root: Path, *, allow_missing: bool = False) -> set[str]: - contents = path.read_bytes().decode("utf-8") - lines = contents.split("\n") - listed_rows = set(lines) - - def is_scope_file(value: str) -> bool: - try: - relative_file(value, repo_root) - except (OSError, ValueError): - return False - return True - - carriage_rows = { - line: (is_scope_file(line), is_scope_file(line.removesuffix("\r"))) - for line in lines - if line.endswith("\r") and line != "\r" and sys.platform != "win32" - } - crlf_evidence = any(line == "\r" for line in lines) or any( - stripped and not literal for literal, stripped in carriage_rows.values() - ) - literal_evidence = any(literal and not stripped for literal, stripped in carriage_rows.values()) - - scope: set[str] = set() - for number, line in enumerate(lines, 1): - if sys.platform == "win32" or line == "\r": - line = line.removesuffix("\r") - elif line.endswith("\r"): - literal, stripped = carriage_rows[line] - if stripped and not literal: - line = line.removesuffix("\r") - elif stripped and literal: - if number == len(lines) and not contents.endswith("\n"): - pass - elif line.removesuffix("\r") in listed_rows: - pass - elif crlf_evidence and not literal_evidence: - line = line.removesuffix("\r") - elif literal_evidence and not crlf_evidence: - pass - else: - raise ValueError(f"in-scope file row {number}: ambiguous carriage-return paths") - elif not literal and crlf_evidence: - line = line.removesuffix("\r") - if not line: - continue - try: - relative, _ = relative_file(line, repo_root) - except (OSError, ValueError) as error: - if allow_missing and isinstance(error, FileNotFoundError): - candidate = PurePosixPath(line) - if candidate.is_absolute() or ".." in candidate.parts or "\0" in line: - raise ValueError(f"in-scope file row {number}: unsafe deleted path") from error - resolved = (repo_root / line).resolve(strict=False) - try: - relative = resolved.relative_to(repo_root).as_posix() - except ValueError as escaped: - raise ValueError( - f"in-scope file row {number}: path escapes repository" - ) from escaped - scope.add(relative) - continue - raise ValueError(f"in-scope file row {number}: {error}") from error - scope.add(relative) - return scope - - -def normalize_candidate( - row: dict[str, Any], repo_root: Path, scope: set[str], line_counts: dict[Path, int] -) -> dict[str, Any]: - unknown = set(row) - FIELDS - if unknown: - raise ValueError(f"unsupported fields {', '.join(sorted(unknown))}") - if "candidate_id" in row: - text_field(row, "candidate_id") - candidate_locations = normalize_locations(row, repo_root, line_counts) - if not any(item["path"] in scope for item in candidate_locations): - raise ValueError("locations: expected at least one in-scope file") - result: dict[str, Any] = { - "cwe_ids": cwe_ids(row), - "locations": candidate_locations, - "summary": text_field(row, "summary"), - "evidence": text_field(row, "evidence"), - } - context = text_field(row, "context", required=False) - if context is not None: - result["context"] = context - instance = text_field(row, "instance", required=False) - if instance is not None: - result["instance"] = instance - return result - - -def identity(row: dict[str, Any]) -> str: - return json.dumps( - { - "cwe_ids": row["cwe_ids"], - "locations": row["locations"], - "instance": row.get("instance"), - }, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ) - - -def merged_text(group: list[dict[str, Any]], field: str) -> str: - return "\n".join(sorted({item[field] for item in group if field in item})) - - -def combine(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - groups: dict[str, list[dict[str, Any]]] = {} - for row in rows: - groups.setdefault(identity(row), []).append(row) - combined: list[dict[str, Any]] = [] - for key, group in sorted(groups.items()): - candidate_id = hashlib.sha256(key.encode()).hexdigest()[:16] - - result = { - "candidate_id": f"candidate-{candidate_id}", - "cwe_ids": group[0]["cwe_ids"], - "locations": group[0]["locations"], - "summary": merged_text(group, "summary"), - "evidence": merged_text(group, "evidence"), - } - context = merged_text(group, "context") - if context: - result["context"] = context - if "instance" in group[0]: - result["instance"] = group[0]["instance"] - combined.append(result) - return combined - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--input", nargs="+", required=True, help="Candidate JSONL inputs.") - parser.add_argument("--out", required=True, help="Combined candidate JSONL output.") - parser.add_argument("--repo-root", required=True, help="Repository root for candidate paths.") - parser.add_argument("--in-scope-files", required=True, help="Repository-relative file list.") - parser.add_argument( - "--allow-missing-in-scope", - action="store_true", - help="Keep deleted Git paths in a diff inventory while validating existing candidate files.", - ) - args = parser.parse_args() - try: - repo_root = Path(args.repo_root).expanduser().resolve(strict=True) - if not repo_root.is_dir(): - raise ValueError("--repo-root: expected a directory") - output = Path(args.out).expanduser().resolve(strict=False) - scope_path = Path(args.in_scope_files).expanduser().resolve(strict=True) - inputs = sorted({Path(value).expanduser().resolve(strict=True) for value in args.input}) - if output in inputs: - raise ValueError("--out: must not also be an input") - if output == scope_path: - raise ValueError("--out: must not replace --in-scope-files") - scope = read_scope(scope_path, repo_root, allow_missing=args.allow_missing_in_scope) - line_counts: dict[Path, int] = {} - rows: list[dict[str, Any]] = [] - for source in inputs: - with source.open(encoding="utf-8") as handle: - for number, line in enumerate(handle, 1): - if not line.strip(): - continue - try: - row = json.loads(line) - if not isinstance(row, dict): - raise ValueError("expected a JSON object") - rows.append(normalize_candidate(row, repo_root, scope, line_counts)) - except (ValueError, TypeError, OSError) as error: - raise ValueError(f"{source} row {number}: {error}") from error - combined = combine(rows) - output.parent.mkdir(parents=True, exist_ok=True) - temporary: Path | None = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=output.parent, - prefix=f".{output.name}.", - suffix=".tmp", - delete=False, - ) as handle: - temporary = Path(handle.name) - for row in combined: - handle.write( - json.dumps(row, ensure_ascii=False, separators=(",", ":"), sort_keys=True) - + "\n" - ) - temporary.replace(output) - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) - print(f"Combined {len(rows)} candidate rows into {len(combined)} rows in {output}") - except (OSError, ValueError) as error: - print(f"normalize_candidates: {error}", file=sys.stderr) - raise SystemExit(2) from error - - -if __name__ == "__main__": - main() diff --git a/plugins/codex-security/skills/security-diff-scan/SKILL.md b/plugins/codex-security/skills/security-diff-scan/SKILL.md index af59e39c2..10faac5b8 100644 --- a/plugins/codex-security/skills/security-diff-scan/SKILL.md +++ b/plugins/codex-security/skills/security-diff-scan/SKILL.md @@ -34,6 +34,6 @@ For terminal scans without a `scanId`, generate the changed-file list with: /scripts/generate_in_scope_files.py --repo --scope . --diff-base --diff-head --diff-mode --out /in_scope_files.txt ``` -Record candidates with `normalize_candidates.py --input --out /candidate_ledger.jsonl --repo-root --in-scope-files /in_scope_files.txt --allow-missing-in-scope`. Add validation and attack-path decisions to that same file. Following `../../references/final-report.md`, assemble unsealed `scan-manifest.json`, `findings.json`, and `coverage.json` before running `finalize_scan_contract.py --scan-dir --source-root `. +Record candidates with `node normalize_candidates.mjs --input --out /candidate_ledger.jsonl --repo-root --in-scope-files /in_scope_files.txt --allow-missing-in-scope`. Repeat `--input ` for additional inputs. Add validation and attack-path decisions to that same file. Following `../../references/final-report.md`, assemble unsealed `scan-manifest.json`, `findings.json`, and `coverage.json` before running `finalize_scan_contract.py --scan-dir --source-root `. Finish only after every changed file and candidate is accounted for. Return the generated report, actual coverage gaps, and Codex review comments for confirmed findings. diff --git a/plugins/codex-security/tests/test_normalize_candidates.py b/plugins/codex-security/tests/test_normalize_candidates.py deleted file mode 100644 index 94290fcb6..000000000 --- a/plugins/codex-security/tests/test_normalize_candidates.py +++ /dev/null @@ -1,789 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import sys -from collections.abc import Callable -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "normalize_candidates.py" - - -def write_jsonl(path: Path, rows: list[dict[str, object]]) -> None: - path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8") - - -def location(path: str, line: int, role: str) -> dict[str, object]: - return {"path": path, "start_line": line, "role": role} - - -def candidate( - locations: list[dict[str, object]], - *, - cwes: list[str] | None = None, - summary: str = "Request input reaches an unsafe operation", - evidence: str = "The input is passed to the operation without a check", - context: str | None = None, - instance: str | None = None, -) -> dict[str, object]: - row: dict[str, object] = { - "cwe_ids": ["CWE-89"] if cwes is None else cwes, - "locations": locations, - "summary": summary, - "evidence": evidence, - } - if context is not None: - row["context"] = context - if instance is not None: - row["instance"] = instance - return row - - -def setup_repo(tmp_path: Path) -> tuple[Path, Path]: - repo = tmp_path / "repo" - for path in ("app/routes.py", "app/query.py", "app/export.py", "helpers/shared.py"): - source = repo / path - source.parent.mkdir(parents=True, exist_ok=True) - source.write_text("one\ntwo\nthree\nfour\nfive\n", encoding="utf-8") - scope = tmp_path / "in_scope_files.txt" - scope.write_text("app/routes.py\napp/query.py\napp/export.py\n", encoding="utf-8") - return repo, scope - - -def run_combiner( - tmp_path: Path, - inputs: list[list[dict[str, object]]], - *, - repo: Path | None = None, - scope: Path | None = None, - output_name: str = "combined.jsonl", - allow_missing: bool = False, -) -> tuple[subprocess.CompletedProcess[str], Path]: - if repo is None or scope is None: - repo, scope = setup_repo(tmp_path) - sources: list[Path] = [] - for index, rows in enumerate(inputs): - source = tmp_path / f"candidates-{index}.jsonl" - write_jsonl(source, rows) - sources.append(source) - output = tmp_path / output_name - result = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--input", - *(str(source) for source in sources), - "--out", - str(output), - "--repo-root", - str(repo), - "--in-scope-files", - str(scope), - *(["--allow-missing-in-scope"] if allow_missing else []), - ], - capture_output=True, - text=True, - ) - return result, output - - -def read_jsonl(path: Path) -> list[dict[str, object]]: - return [json.loads(line) for line in path.read_text(encoding="utf-8").split("\n") if line] - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -@pytest.mark.parametrize( - "relative", - [ - "app/ leading.py", - "app/trailing .py", - "app/ .py", - "app/ .py", - "app/carriage\rname.py", - "app/trailing-carriage.py\r", - "app/vertical\vname.py", - "app/form\fname.py", - "app/next\u0085name.py", - "app/line\u2028name.py", - "app/paragraph\u2029name.py", - ], -) -def test_scope_preserves_literal_posix_inventory_paths(tmp_path: Path, relative: str) -> None: - repo, scope = setup_repo(tmp_path) - source = repo / relative - source.write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes((relative + "\n").encode("utf-8")) - - result, output = run_combiner( - tmp_path, - [[candidate([location(relative, 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == relative - - -def test_scope_accepts_crlf_inventory_on_every_platform(tmp_path: Path) -> None: - repo, scope = setup_repo(tmp_path) - scope.write_bytes(b"\r\napp/routes.py\r\napp/query.py\r\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == "app/routes.py" - - -def test_diff_scope_accepts_deleted_files_without_weakening_candidate_locations( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - scope.write_text("app/deleted_guard.py\napp/routes.py\n", encoding="utf-8") - - rejected, _ = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - assert rejected.returncode == 2 - assert "in-scope file row 1" in rejected.stderr - - accepted, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - allow_missing=True, - ) - assert accepted.returncode == 0, accepted.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == "app/routes.py" - - missing_candidate, _ = run_combiner( - tmp_path, - [[candidate([location("app/deleted_guard.py", 1, "root_control")])]], - repo=repo, - scope=scope, - output_name="missing-candidate.jsonl", - allow_missing=True, - ) - assert missing_candidate.returncode == 2 - assert "deleted_guard.py" in missing_candidate.stderr - - -def test_diff_scope_rejects_deleted_path_outside_repository(tmp_path: Path) -> None: - repo, scope = setup_repo(tmp_path) - scope.write_text("../deleted_guard.py\napp/routes.py\n", encoding="utf-8") - - result, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - allow_missing=True, - ) - - assert result.returncode == 2 - assert "in-scope file row 1" in result.stderr - assert not output.exists() - - -@pytest.mark.parametrize( - "inventory", - [ - b"app/routes.py\r\napp/query.py\n", - b"app/routes.py\r\napp/query.py", - ], -) -def test_scope_accepts_mixed_and_unterminated_crlf_inventories( - tmp_path: Path, inventory: bytes -) -> None: - repo, scope = setup_repo(tmp_path) - scope.write_bytes(inventory) - - result, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == "app/routes.py" - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -def test_crlf_scope_preserves_paths_when_carriage_return_names_also_exist( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - (repo / "app/routes.py\r").write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"app/routes.py\r\napp/query.py\r\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == "app/routes.py" - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -@pytest.mark.parametrize("candidate_path", ["app/routes.py", "app/routes.py\r"]) -def test_lf_scope_preserves_separately_listed_carriage_return_collisions( - tmp_path: Path, candidate_path: str -) -> None: - repo, scope = setup_repo(tmp_path) - for relative in ("app/routes.py\r", "app/query.py\r"): - (repo / relative).write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"app/query.py\napp/query.py\r\napp/routes.py\napp/routes.py\r\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location(candidate_path, 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == candidate_path - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -def test_scope_preserves_unterminated_final_carriage_return_filename( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - relative = "app/routes.py\r" - (repo / relative).write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"app/query.py\r\napp/routes.py\r") - - result, output = run_combiner( - tmp_path, - [[candidate([location(relative, 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == relative - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -@pytest.mark.parametrize( - "relative", - ["app/literal\\name.py", "app/C:foo.py", " ", " "], -) -def test_candidate_rejects_paths_incompatible_with_scan_contract( - tmp_path: Path, relative: str -) -> None: - repo, scope = setup_repo(tmp_path) - source = repo / relative - source.write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes((relative + "\n").encode("utf-8")) - - result, output = run_combiner( - tmp_path, - [[candidate([location(relative, 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 2 - assert "safe repository-relative POSIX path" in result.stderr - assert not output.exists() - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -@pytest.mark.parametrize("candidate_path", ["app/routes.py", "app/routes.py\r"]) -def test_mixed_scope_rejects_colliding_carriage_return_file_names( - tmp_path: Path, candidate_path: str -) -> None: - repo, scope = setup_repo(tmp_path) - (repo / "app/routes.py\r").write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"app/routes.py\r\napp/query.py\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location(candidate_path, 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 2 - assert "ambiguous carriage-return paths" in result.stderr - assert not output.exists() - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -def test_lf_scope_uses_independent_literal_carriage_return_evidence( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - relative = "app/routes.py\r" - evidence = "app/literal-evidence.py\r" - for path in (relative, evidence): - (repo / path).write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"app/routes.py\r\napp/literal-evidence.py\r\napp/query.py\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location(relative, 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == relative - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -def test_scope_rejects_indistinguishable_carriage_return_inventories( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - for relative in ("app/routes.py\r", "app/query.py\r"): - (repo / relative).write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"app/routes.py\r\napp/query.py\r\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 2 - assert "ambiguous carriage-return paths" in result.stderr - assert not output.exists() - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only literal file names") -def test_crlf_blank_line_disambiguates_colliding_carriage_return_paths( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - for relative in ("app/routes.py\r", "app/query.py\r"): - (repo / relative).write_text("one\ntwo\n", encoding="utf-8") - scope.write_bytes(b"\r\napp/routes.py\r\napp/query.py\r\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location("app/routes.py", 1, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == "app/routes.py" - - -@pytest.mark.skipif(sys.platform != "win32", reason="Windows-native file paths") -def test_windows_scope_normalizes_native_separators(tmp_path: Path) -> None: - repo, scope = setup_repo(tmp_path) - scope.write_bytes(b"app\\routes.py\r\n") - - result, output = run_combiner( - tmp_path, - [[candidate([location("app\\routes.py", 2, "entrypoint")])]], - repo=repo, - scope=scope, - ) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["locations"][0]["path"] == "app/routes.py" - - -@pytest.mark.skipif(sys.platform != "win32", reason="Windows-native drive paths") -def test_windows_candidates_reject_drive_qualified_paths(tmp_path: Path) -> None: - result, output = run_combiner( - tmp_path, - [[candidate([location("C:routes.py", 1, "entrypoint")])]], - ) - - assert result.returncode == 2 - assert "repository-relative path without traversal" in result.stderr - assert not output.exists() - - -def test_matching_code_paths_combine_even_when_the_prose_differs( - tmp_path: Path, -) -> None: - first = candidate( - [ - location("app/query.py", 4, "sink"), - location("app/routes.py", 2, "entrypoint"), - location("app/query.py", 3, "root_control"), - ], - cwes=["cwe-089", "CWE-89"], - summary="The request id reaches an interpolated query", - evidence="The query uses an f-string", - context="Intentional training application", - ) - second = candidate( - [ - location("app/query.py", 3, "root_control"), - location("app/routes.py", 2, "entrypoint"), - location("app/query.py", 4, "sink"), - location("app/query.py", 4, "sink"), - ], - summary="SQL injection is reachable from the request", - evidence="The id is inserted directly into SQL", - context="Runs only on localhost", - ) - - result, output = run_combiner(tmp_path, [[first], [second]]) - - assert result.returncode == 0, result.stderr - rows = read_jsonl(output) - assert len(rows) == 1 - assert rows[0]["candidate_id"].startswith("candidate-") - assert rows[0]["cwe_ids"] == ["CWE-89"] - assert rows[0]["locations"] == [ - {"path": "app/routes.py", "start_line": 2, "end_line": 2, "role": "entrypoint"}, - { - "path": "app/query.py", - "start_line": 3, - "end_line": 3, - "role": "root_control", - }, - {"path": "app/query.py", "start_line": 4, "end_line": 4, "role": "sink"}, - ] - assert rows[0]["summary"] == ( - "SQL injection is reachable from the request\nThe request id reaches an interpolated query" - ) - assert rows[0]["evidence"] == "The id is inserted directly into SQL\nThe query uses an f-string" - assert rows[0]["context"] == "Intentional training application\nRuns only on localhost" - - -@pytest.mark.parametrize( - "first_locations,second_locations", - [ - ( - [ - location("app/routes.py", 2, "entrypoint"), - location("helpers/shared.py", 3, "root_control"), - location("app/query.py", 4, "sink"), - ], - [ - location("app/routes.py", 2, "entrypoint"), - location("helpers/shared.py", 3, "root_control"), - location("app/export.py", 4, "sink"), - ], - ), - ( - [ - location("app/routes.py", 2, "entrypoint"), - location("helpers/shared.py", 3, "root_control"), - location("app/query.py", 4, "sink"), - ], - [ - location("app/export.py", 2, "entrypoint"), - location("helpers/shared.py", 3, "root_control"), - location("app/query.py", 4, "sink"), - ], - ), - ], -) -def test_different_reachable_paths_remain_separate( - tmp_path: Path, - first_locations: list[dict[str, object]], - second_locations: list[dict[str, object]], -) -> None: - result, output = run_combiner( - tmp_path, - [[candidate(first_locations), candidate(second_locations)]], - ) - - assert result.returncode == 0, result.stderr - rows = read_jsonl(output) - assert len(rows) == 2 - assert rows[0]["candidate_id"] != rows[1]["candidate_id"] - - -def test_separate_bugs_at_the_same_locations_can_use_an_instance_label( - tmp_path: Path, -) -> None: - locations = [ - location("app/routes.py", 2, "entrypoint"), - location("app/query.py", 4, "sink"), - ] - first = candidate(locations, instance="id parameter") - second = candidate(locations, instance="sort parameter") - - result, output = run_combiner(tmp_path, [[first, second]]) - - assert result.returncode == 0, result.stderr - rows = read_jsonl(output) - assert len(rows) == 2 - assert {row["instance"] for row in rows} == {"id parameter", "sort parameter"} - assert rows[0]["candidate_id"] != rows[1]["candidate_id"] - - -def test_output_is_stable_when_input_and_row_order_changes(tmp_path: Path) -> None: - duplicate_one = candidate( - [ - location("app/routes.py", 2, "entrypoint"), - location("app/query.py", 4, "sink"), - ], - summary="Request id reaches SQL", - evidence="The query interpolates id", - ) - duplicate_two = candidate( - [ - location("app/query.py", 4, "sink"), - location("app/routes.py", 2, "entrypoint"), - ], - summary="SQL is built from request input", - evidence="The id is part of the query string", - ) - separate = candidate( - [ - location("app/export.py", 2, "entrypoint"), - location("app/export.py", 4, "sink"), - ], - cwes=["CWE-22"], - summary="The export path is unsafe", - evidence="The path is joined without a containment check", - ) - - first, first_output = run_combiner( - tmp_path, - [[duplicate_one, separate], [duplicate_two]], - output_name="first.jsonl", - ) - second, second_output = run_combiner( - tmp_path, - [[duplicate_two], [separate, duplicate_one]], - output_name="second.jsonl", - ) - - assert first.returncode == 0, first.stderr - assert second.returncode == 0, second.stderr - assert first_output.read_bytes() == second_output.read_bytes() - - -def test_combined_output_can_be_combined_again_without_changing_it( - tmp_path: Path, -) -> None: - repo, scope = setup_repo(tmp_path) - locations = [ - location("app/routes.py", 2, "entrypoint"), - location("app/query.py", 4, "sink"), - ] - first, first_output = run_combiner( - tmp_path, - [ - [ - candidate(locations, evidence="First trace"), - candidate(locations, evidence="Second trace"), - ] - ], - repo=repo, - scope=scope, - output_name="first.jsonl", - ) - second_output = tmp_path / "second.jsonl" - second = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--input", - str(first_output), - "--out", - str(second_output), - "--repo-root", - str(repo), - "--in-scope-files", - str(scope), - ], - capture_output=True, - text=True, - ) - - assert first.returncode == 0, first.stderr - assert second.returncode == 0, second.stderr - assert first_output.read_bytes() == second_output.read_bytes() - - -def test_multiline_candidate_text_keeps_its_original_order(tmp_path: Path) -> None: - summary = "Z: attacker reaches the route.\nA: the route then reaches the sink." - evidence = "Z: read the request.\nA: pass it into the query." - context = "Z: enabled in the demo.\nA: runs only on localhost." - row = candidate( - [location("app/routes.py", 2, "entrypoint")], - summary=summary, - evidence=evidence, - context=context, - ) - - result, output = run_combiner(tmp_path, [[row]]) - - assert result.returncode == 0, result.stderr - combined = read_jsonl(output)[0] - assert combined["summary"] == summary - assert combined["evidence"] == evidence - assert combined["context"] == context - - -def test_unknown_cwe_and_supporting_location_outside_scope_are_allowed( - tmp_path: Path, -) -> None: - row = candidate( - [ - location("app/routes.py", 2, "entrypoint"), - location("helpers/shared.py", 3, "root_control"), - ], - cwes=[], - ) - - result, output = run_combiner(tmp_path, [[row]]) - - assert result.returncode == 0, result.stderr - assert read_jsonl(output)[0]["cwe_ids"] == [] - - -@pytest.mark.parametrize( - ("change", "message"), - [ - (lambda row: row.pop("cwe_ids"), "cwe_ids: expected an array"), - ( - lambda row: row.update(cwe_ids=["SQL injection"]), - "cwe_ids: unsupported value", - ), - (lambda row: row.update(locations=[]), "locations: expected a non-empty array"), - ( - lambda row: row["locations"][0].update(path="app/missing.py"), - "missing.py", - ), - ( - lambda row: row["locations"][0].update(path="../outside.py"), - "repository-relative path without traversal", - ), - ( - lambda row: row["locations"][0].update(start_line=6), - "line range 6-6 exceeds app/routes.py:5", - ), - ( - lambda row: row["locations"][0].update(end_line=1), - "greater than or equal", - ), - ( - lambda row: row["locations"][0].update(role="rootControl"), - "role: unsupported value", - ), - ( - lambda row: row["locations"][0].update(line=2), - "locations: unsupported fields line", - ), - ( - lambda row: row.update(technically_validated=True), - "unsupported fields technically_validated", - ), - ( - lambda row: row.update(disposition="reportable"), - "unsupported fields disposition", - ), - ], -) -def test_invalid_candidates_fail_with_the_input_row( - tmp_path: Path, change: Callable[[dict[str, object]], object], message: str -) -> None: - valid = candidate([location("app/routes.py", 2, "entrypoint")]) - invalid = candidate([location("app/routes.py", 2, "entrypoint")]) - change(invalid) - - result, output = run_combiner(tmp_path, [[valid, invalid]]) - - assert result.returncode == 2 - assert "candidates-0.jsonl row 2:" in result.stderr - assert message in result.stderr - assert not output.exists() - - -def test_candidate_with_no_in_scope_location_fails(tmp_path: Path) -> None: - row = candidate([location("helpers/shared.py", 3, "root_control")]) - - result, output = run_combiner(tmp_path, [[row]]) - - assert result.returncode == 2 - assert "expected at least one in-scope file" in result.stderr - assert not output.exists() - - -def test_malformed_json_does_not_replace_an_existing_output(tmp_path: Path) -> None: - repo, scope = setup_repo(tmp_path) - source = tmp_path / "candidates.jsonl" - source.write_text( - json.dumps(candidate([location("app/routes.py", 2, "entrypoint")])) + "\nnot-json\n", - encoding="utf-8", - ) - output = tmp_path / "combined.jsonl" - output.write_text("previous output\n", encoding="utf-8") - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--input", - str(source), - "--out", - str(output), - "--repo-root", - str(repo), - "--in-scope-files", - str(scope), - ], - capture_output=True, - text=True, - ) - - assert result.returncode == 2 - assert "candidates.jsonl row 2:" in result.stderr - assert output.read_text(encoding="utf-8") == "previous output\n" - - -def test_empty_candidate_input_produces_an_empty_ledger(tmp_path: Path) -> None: - result, output = run_combiner(tmp_path, [[]]) - - assert result.returncode == 0, result.stderr - assert output.read_text(encoding="utf-8") == "" - - -def test_output_cannot_replace_the_in_scope_file_list(tmp_path: Path) -> None: - repo, scope = setup_repo(tmp_path) - source = tmp_path / "candidates.jsonl" - write_jsonl(source, [candidate([location("app/routes.py", 2, "entrypoint")])]) - original_scope = scope.read_text(encoding="utf-8") - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--input", - str(source), - "--out", - str(scope), - "--repo-root", - str(repo), - "--in-scope-files", - str(scope), - ], - capture_output=True, - text=True, - ) - - assert result.returncode == 2 - assert "--out: must not replace --in-scope-files" in result.stderr - assert scope.read_text(encoding="utf-8") == original_scope diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index 1531e4a56..d57299a5e 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -84,9 +84,9 @@ default to 100 cases; filesystem contract properties default to 20. ### TypeScript candidate normalizer -The prototype lives in `plugins/codex-security/scripts/normalize_candidates.ts`. +The normalizer lives in `plugins/codex-security/scripts/normalize_candidates.ts`. `build:plugin` compiles it into the ignored `_bundled_plugin` payload. -Production callers still use the unchanged Python helper. +The discovery tool and diff-scan workflow use the generated Node helper. ```sh pnpm run build:plugin @@ -100,10 +100,9 @@ normal resolution rules. JSONL and scope files use LF or CRLF lines; and LF line endings, and atomic replacement replaces an output symlink rather than modifying its target. -Candidate IDs use a fixed TypeScript object shape and may differ from the -production helper. The tests cover normalization, deterministic output, scan -boundaries, and atomic writes. Run them on Linux, macOS, and Windows before -changing the production entrypoint. +Candidate IDs use a fixed object shape. The tests cover normalization, +deterministic output, scan boundaries, and atomic writes across Linux, macOS, +and Windows. ## GitHub Actions diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 312e697bc..0fcfbbb6d 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.80" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.79" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/compact-diff-scan.test.ts b/sdk/typescript/tests-ts/compact-diff-scan.test.ts index f117cb0c3..82c7108d6 100644 --- a/sdk/typescript/tests-ts/compact-diff-scan.test.ts +++ b/sdk/typescript/tests-ts/compact-diff-scan.test.ts @@ -14,6 +14,7 @@ import { createInterface } from "node:readline"; import { afterEach, describe, expect, test } from "bun:test"; import { loadContract } from "../src/index.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; +import { runNormalizer } from "./support/normalize-candidates.js"; type JsonObject = Record; @@ -389,12 +390,8 @@ describe("compact diff scan", () => { inventory, ]; - expect(python("normalize_candidates.py", ...args).status).toBe(2); - const accepted = python( - "normalize_candidates.py", - ...args, - "--allow-missing-in-scope", - ); + expect(runNormalizer(args).status).toBe(2); + const accepted = runNormalizer([...args, "--allow-missing-in-scope"]); expect(accepted.status, accepted.stderr).toBe(0); const contents = readFileSync(output, "utf8"); expect(contents).toContain("Résumé: missing guard"); @@ -408,11 +405,7 @@ describe("compact diff scan", () => { ]); writeFileSync(inventory, "../escaped.py\nsrc/handler.py\n"); - const escaped = python( - "normalize_candidates.py", - ...args, - "--allow-missing-in-scope", - ); + const escaped = runNormalizer([...args, "--allow-missing-in-scope"]); expect(escaped.status).toBe(2); expect(escaped.stderr).toContain("in-scope file row 1"); }); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 09441855f..4df14e334 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -773,152 +773,6 @@ describe("plugin runtime preparation", () => { ).toBeDefined(); }); - testPosix( - "preserves literal POSIX candidate paths in the bundled plugin", - async () => { - const root = await temporaryDirectory(); - await mkdir(join(root, "source")); - const cases = [ - { path: "source\\candidate.py", contents: "literal candidate\n" }, - { path: " leading.py", contents: "leading whitespace\n" }, - { path: "trailing.py ", contents: "trailing whitespace\n" }, - { path: " ", contents: "single whitespace filename\n" }, - { path: " ", contents: "multiple whitespace filename\n" }, - { path: "C:candidate.py", contents: "literal colon\n" }, - { path: "carriage\rreturn.py", contents: "literal carriage return\n" }, - { path: "vertical\vtab.py", contents: "literal vertical tab\n" }, - { path: "form\ffeed.py", contents: "literal form feed\n" }, - { path: "next\u0085line.py", contents: "literal next line\n" }, - { - path: "unicode\u2028separator.py", - contents: "literal line separator\n", - }, - { - path: "paragraph\u2029separator.py", - contents: "literal paragraph separator\n", - }, - ]; - await Promise.all([ - ...cases.map((item) => writeFile(join(root, item.path), item.contents)), - writeFile(join(root, "source", "candidate.py"), "wrong candidate\n"), - writeFile(join(root, "leading.py"), "wrong leading candidate\n"), - writeFile(join(root, "trailing.py"), "wrong trailing candidate\n"), - ]); - const scopePath = join(root, "in-scope-files.txt"); - await writeFile( - scopePath, - `${cases.map((item) => item.path).join("\n")}\n`, - ); - - const python = Bun.which("python3") ?? Bun.which("python"); - expect(python).not.toBeNull(); - const sourcePlugin = await bundledPluginRoot(); - const projector = new URL( - "../scripts/project-plugin.mjs", - import.meta.url, - ); - const publicManifest = new URL( - "../public-repo/sdk/typescript/plugin.public.json", - import.meta.url, - ); - let bundledPlugin = sourcePlugin; - if (existsSync(projector) && existsSync(publicManifest)) { - const packageRoot = join(root, "package"); - const isolatedProjector = join( - packageRoot, - "scripts", - "project-plugin.mjs", - ); - const isolatedManifest = join( - packageRoot, - "public-repo", - "sdk", - "typescript", - "plugin.public.json", - ); - await Promise.all([ - mkdir(dirname(isolatedProjector), { recursive: true }), - mkdir(dirname(isolatedManifest), { recursive: true }), - ]); - await Promise.all([ - copyFile(projector, isolatedProjector), - copyFile(publicManifest, isolatedManifest), - ]); - const projection = Bun.spawnSync( - [process.execPath, isolatedProjector], - { - cwd: packageRoot, - env: { - ...process.env, - CODEX_SECURITY_PLUGIN_ROOT: sourcePlugin, - }, - stdout: "pipe", - stderr: "pipe", - }, - ); - expect(new TextDecoder().decode(projection.stderr)).toBe(""); - expect(projection.exitCode).toBe(0); - bundledPlugin = join(packageRoot, "_bundled_plugin"); - } - const normalizer = join( - bundledPlugin, - "scripts", - "normalize_candidates.py", - ); - expect(await readFile(normalizer, "utf8")).toBe( - await readFile( - join(sourcePlugin, "scripts", "normalize_candidates.py"), - "utf8", - ), - ); - const result = Bun.spawnSync([ - python!, - "-I", - "-B", - "-c", - [ - "import json, pathlib, runpy, sys", - "module = runpy.run_path(sys.argv[1])", - "root = pathlib.Path(sys.argv[2])", - "scope = module['read_scope'](pathlib.Path(sys.argv[3]), root)", - "finalizer = runpy.run_path(sys.argv[5])", - "results = []", - "for value in json.loads(sys.argv[4]):", - " path, source = module['relative_file'](value, root)", - " candidate = {'cwe_ids': ['CWE-89'], 'locations': [{'path': value, 'start_line': 1, 'role': 'entrypoint'}], 'summary': 'Test finding', 'evidence': 'Test evidence'}", - " try:", - " normalized = module['normalize_candidate'](candidate, root, scope, {})", - " location = normalized['locations'][0]", - " finalizer['_validate_location']({'path': location['path'], 'startLine': location['start_line'], 'endLine': location['end_line'], 'role': location['role']}, 'candidate.locations[0]')", - " except ValueError:", - " contract_valid = False", - " else:", - " contract_valid = True", - " results.append({'path': path, 'contents': source.read_text(encoding='utf-8'), 'inScope': path in scope, 'contractValid': contract_valid})", - "print(json.dumps(results))", - ].join("\n"), - normalizer, - root, - scopePath, - JSON.stringify(cases.map((item) => item.path)), - join(bundledPlugin, "scripts", "finalize_scan_contract.py"), - ]); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual( - cases.map((item) => ({ - ...item, - inScope: true, - contractValid: - item.path.trim().length > 0 && - !/^[A-Za-z]:/.test(item.path) && - !item.path.includes("\\") && - !/[\u0000-\u001f]/u.test(item.path), - })), - ); - }, - ); - test("uses a configured plugin directory directly", async () => { const root = await temporaryDirectory(); const ambientHome = join(root, ".codex", "plugins", "cache"); @@ -1963,10 +1817,6 @@ describe("plugin runtime preparation", () => { test("upgrades the predecessor cache and restores with the SDK-owned helper", async () => { const root = await temporaryDirectory(); const previous = await plugin(join(root, "previous"), "0.1.60"); - await writeFile( - join(previous, "scripts", "normalize_candidates.mjs"), - "throw new Error('stale normalizer must be replaced');\n", - ); // Keep the stale MCP configuration regression covered while upgrading the // current predecessor cache to the generated bundle. await writeFile( @@ -2013,25 +1863,11 @@ describe("plugin runtime preparation", () => { expect(upgraded.version).toBe(BUNDLED_PLUGIN_VERSION); expect(upgraded.version).not.toBe(stale.version); expect(upgraded.installedRoot).not.toBe(stale.installedRoot); - for (const script of [ - "workbench_target.py", - "finalize_scan_contract.py", - "normalize_candidates.mjs", - ]) { + for (const script of ["workbench_target.py", "finalize_scan_contract.py"]) { expect( await readFile(join(upgraded.installedRoot, "scripts", script)), ).toEqual(await readFile(join(PLUGIN_ROOT, "scripts", script))); } - const help = spawnSync( - "node", - [ - join(upgraded.installedRoot, "scripts", "normalize_candidates.mjs"), - "--help", - ], - { encoding: "utf8" }, - ); - expect(help.status, help.stderr).toBe(0); - expect(help.stdout).toContain("Usage:"); const configuration = JSON.parse( await readFile(join(upgraded.installedRoot, ".mcp.json"), "utf8"), ) as { From 185aee92294fa88d94d5725bcfe2be796a8d6499 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 07:53:56 -0700 Subject: [PATCH 6/8] fix(plugin): count carriage-return source lines --- .../scripts/normalize_candidates.ts | 6 ++---- .../tests-ts/normalize-candidates.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/scripts/normalize_candidates.ts b/plugins/codex-security/scripts/normalize_candidates.ts index 4ab22ffe5..264747d26 100644 --- a/plugins/codex-security/scripts/normalize_candidates.ts +++ b/plugins/codex-security/scripts/normalize_candidates.ts @@ -132,10 +132,8 @@ function cweIds(value: unknown): string[] { } function countLines(path: string): number { - const contents = readFileSync(path, "utf8"); - return contents === "" - ? 0 - : contents.split("\n").length - (contents.endsWith("\n") ? 1 : 0); + const lines = readFileSync(path, "utf8").split(/\r\n|\r|\n/u); + return lines.length - (lines.at(-1) === "" ? 1 : 0); } function compareStrings(left: string, right: string): number { diff --git a/sdk/typescript/tests-ts/normalize-candidates.test.ts b/sdk/typescript/tests-ts/normalize-candidates.test.ts index 28ed7a122..9188e3846 100644 --- a/sdk/typescript/tests-ts/normalize-candidates.test.ts +++ b/sdk/typescript/tests-ts/normalize-candidates.test.ts @@ -111,6 +111,21 @@ describe("candidate normalizer", () => { }, ); + test.each(["\n", "\r\n", "\r"])( + "counts source lines separated by %j", + (newline) => { + const { repository } = fixture(); + writeSource(repository, location.path, `one${newline}two${newline}`); + const row = normalizeCandidate( + { ...candidate, locations: [{ ...location, start_line: 2 }] }, + repository, + new Set([location.path]), + new Map(), + ); + expect(row.locations[0]?.end_line).toBe(2); + }, + ); + test.each([ { name: "empty locations", patch: { locations: [] } }, { name: "blank summary", patch: { summary: " \t" } }, From c786f7db7d90bf5a8301953fdfc6256196fd100e Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 08:02:40 -0700 Subject: [PATCH 7/8] fix(sdk): refresh diff workflow fingerprint --- sdk/typescript/src/custom-validation-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/src/custom-validation-prompt.ts b/sdk/typescript/src/custom-validation-prompt.ts index 5e04eddcc..8c5b0e99e 100644 --- a/sdk/typescript/src/custom-validation-prompt.ts +++ b/sdk/typescript/src/custom-validation-prompt.ts @@ -13,7 +13,7 @@ const SOURCES = { "skills/security-scan/SKILL.md": "5b8f5d7debeca14c6b37e8e7ba737671362b8eb4b7f49e693c99c6bd04bc8fa0", "skills/security-diff-scan/SKILL.md": - "0a4c519ad713585876ea7eb0a8af4b59892c86746f4c69851db9ab347b7fad2f", + "cafd0d2b3efe31ac5ffe04160333a580255471f9e999d27da7641f730ba83ffd", } as const; const DISABLED_TOOLS = [ From 07cd80acbd6d86b280bac9b641400abbd669a019 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 08:16:41 -0700 Subject: [PATCH 8/8] docs: remove unnecessary normalizer testing section --- sdk/typescript/TESTING.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index d57299a5e..11a7c1074 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -82,28 +82,6 @@ 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. -### TypeScript candidate normalizer - -The normalizer lives in `plugins/codex-security/scripts/normalize_candidates.ts`. -`build:plugin` compiles it into the ignored `_bundled_plugin` payload. -The discovery tool and diff-scan workflow use the generated Node helper. - -```sh -pnpm run build:plugin -bun test --timeout 30000 tests-ts/normalize-candidates.test.ts tests-ts/normalize-candidates-filesystem.test.ts tests-ts/normalize-candidates.property.test.ts -``` - -The CLI uses Node's argument parser. Repeat `--input FILE` for each input, use -full option names, and let the shell expand home paths. Paths follow Node's -normal resolution rules. JSONL and scope files use LF or CRLF lines; -`--allow-missing-in-scope` skips missing entries. Output uses `JSON.stringify` -and LF line endings, and atomic replacement replaces an output symlink rather -than modifying its target. - -Candidate IDs use a fixed object shape. The tests cover normalization, -deterministic output, scan boundaries, and atomic writes across Linux, macOS, -and Windows. - ## GitHub Actions `node-ci` retains the required `ubuntu-latest / node-22`,