diff --git a/packages/tooling/vis/__tests__/security/multi-eco-lockfiles.fuzz.test.ts b/packages/tooling/vis/__tests__/security/multi-eco-lockfiles.fuzz.test.ts new file mode 100644 index 00000000..7f9fa918 --- /dev/null +++ b/packages/tooling/vis/__tests__/security/multi-eco-lockfiles.fuzz.test.ts @@ -0,0 +1,150 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { lockedPackagesForEcosystem } from "../../src/security/multi-eco-lockfiles"; + +const ITERATIONS = 300; +const RNG_SEED = 1337; + +// Deterministic Lehmer generator (MINSTD). Products stay below 2^53, so the sequence keeps its full period. +// A failing iteration replays from RNG_SEED and its index. +const createRandom = (seed: number): (() => number) => { + let state = seed; + + return (): number => { + state = (state * 48_271) % 2_147_483_647; + + return state / 2_147_483_647; + }; +}; + +/* eslint-disable @stylistic/quotes -- sample lockfiles embed double quotes; prettier picks single quotes to avoid escapes */ +const SAMPLES: Record = { + "Cargo.lock": { + ecosystem: "cargo", + sample: '[[package]]\nname = "serde"\nversion = "1.0.0"\n\n[[package]]\nname = "tokio"\nversion = "1.38.0"\n', + }, + "composer.lock": { + ecosystem: "composer", + sample: '{"packages":[{"name":"a/b","version":"1.0.0"}],"packages-dev":[{"name":"c/d","version":"2.0.0"}]}', + }, + "Gemfile.lock": { + ecosystem: "rubygems", + sample: "GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.1)\n\nPLATFORMS\n ruby\n", + }, + "go.sum": { + ecosystem: "go", + sample: "github.com/a/b v1.2.3 h1:abc=\ngithub.com/a/b v1.2.3/go.mod h1:def=\n", + }, + "gradle.lockfile": { + ecosystem: "maven", + sample: "# Generated by Gradle\norg.example:lib:1.2.3=compileClasspath,runtimeClasspath\nempty=annotationProcessor\n", + }, + "mix.lock": { + ecosystem: "hex", + sample: '%{\n "jason": {:hex, :jason, "1.4.0", "hash", [:mix], [], "hexpm", "x"},\n}\n', + }, + "Package.resolved": { + ecosystem: "swift", + sample: '{"pins":[{"identity":"a","location":"https://example.com/a.git","state":{"version":"1.0.0"}}],"version":2}', + }, + "packages.lock.json": { + ecosystem: "nuget", + sample: '{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}', + }, + "Pipfile.lock": { + ecosystem: "pypi", + sample: '{"default":{"requests":{"version":"==2.31.0"}},"develop":{"pytest":{"version":"==8.0.0"}}}', + }, + "pom.xml": { + ecosystem: "maven", + sample: "4.0.0ga1org.xlib1.2.3", + }, + "pubspec.lock": { + ecosystem: "pub", + sample: 'packages:\n path:\n dependency: "direct main"\n source: hosted\n version: "1.8.0"\nsdks:\n dart: ">=3.0.0 <4.0.0"\n', + }, + "uv.lock": { + ecosystem: "pypi", + sample: '[[package]]\nname = "requests"\nversion = "2.31.0"\n', + }, +}; + +/* eslint-enable @stylistic/quotes */ + +const ALPHABET = `{}[]()"'\\:,=#%\n\r\t -./0123456789abcdefghijklmnopqrstuvwxyz\u0000\uFFFD`; + +// Mutates a valid sample: a few deletes and inserts with a rare truncation, or pure noise about one time in ten. +// Truncation stays rare because a truncated JSON sample fails at parse time and never reaches the post-parse code. +const mutate = (sample: string, random: () => number): string => { + if (random() < 0.1) { + return Array.from({ length: Math.floor(random() * 256) }, () => String.fromCodePoint(Math.floor(random() * 2048))).join(""); + } + + let out = sample; + const ops = 1 + Math.floor(random() * 4); + + for (let i = 0; i < ops; i++) { + const position = Math.floor(random() * (out.length + 1)); + const kind = random(); + + if (kind < 0.475) { + out = out.slice(0, position) + out.slice(position + 1); + } else if (kind < 0.95) { + out = out.slice(0, position) + ALPHABET.charAt(Math.floor(random() * ALPHABET.length)) + out.slice(position); + } else { + out = out.slice(0, position); + } + } + + return out; +}; + +describe(lockedPackagesForEcosystem, () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "vis-lockfile-fuzz-")); + }); + + afterAll(() => { + rmSync(root, { force: true, recursive: true }); + }); + + it.each(Object.entries(SAMPLES))("parses the %s sample and survives mutated inputs without throwing", (file, { ecosystem, sample }) => { + expect.assertions(2); + + const dir = mkdtempSync(join(root, "case-")); + + // Guard against a sample that silently stops parsing, which would make the fuzz loop vacuous. + writeFileSync(join(dir, file), sample); + + expect(lockedPackagesForEcosystem(dir, ecosystem).length).toBeGreaterThan(0); + + const random = createRandom(RNG_SEED); + const failures: string[] = []; + + for (let i = 0; i < ITERATIONS; i++) { + const input = mutate(sample, random); + + writeFileSync(join(dir, file), input); + + try { + const packages = lockedPackagesForEcosystem(dir, ecosystem); + + if ( + !packages.every((pkg) => typeof pkg.name === "string" && typeof pkg.version === "string" && pkg.name.length > 0 && pkg.version.length > 0) + ) { + failures.push(`iteration ${i}: malformed entry for input ${JSON.stringify(input)}`); + } + } catch (error) { + failures.push(`iteration ${i}: ${String(error)} for input ${JSON.stringify(input)}`); + } + } + + expect({ count: failures.length, first: failures.slice(0, 5) }).toStrictEqual({ count: 0, first: [] }); + }); +});