diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cdccf6..1bd782b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,33 @@ concurrency: cancel-in-progress: true jobs: + capability-contract: + name: Capability contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # The version check compares the manifest against the base branch, so it needs history. + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Manifest is up to date with its source + run: node scripts/emit-capabilities.mjs --check + + - name: Vocabulary changes carry a version bump + # A member added or removed without moving CAPABILITY_VERSION leaves every vendoring consumer + # unable to tell it is behind — the same silent drift the manifest exists to remove. + run: node scripts/check-capability-version.mjs --base "origin/${{ github.base_ref || github.event.repository.default_branch }}" + validate: name: Validate on Node ${{ matrix.node-version }} runs-on: ubuntu-latest diff --git a/capabilities.json b/capabilities.json new file mode 100644 index 0000000..5dbbed1 --- /dev/null +++ b/capabilities.json @@ -0,0 +1,62 @@ +{ + "$comment": "GENERATED from src/map/capabilities.ts by scripts/emit-capabilities.mjs — do not edit by hand. The single versioned definition of what the input-flow map can describe, vendored by the reachability recipe schema/validator and by the server that binds coordinates into rules.", + "version": "1.0.0", + "sinkKinds": [ + "db", + "fs", + "http", + "exec", + "eval" + ], + "argumentRoles": [ + "command", + "file", + "args", + "url", + "init", + "body", + "options", + "path", + "content", + "sql", + "values", + "columns", + "column", + "value", + "code", + "unknown" + ], + "candidateFamilies": [ + "ssrf", + "command-injection", + "path-traversal", + "sql-injection", + "code-injection" + ], + "confidenceTiers": [ + "exact-local", + "transformed-local", + "imported", + "heuristic", + "unknown" + ], + "provenConfidenceTiers": [ + "exact-local", + "transformed-local" + ], + "autoPromotableConfidence": "exact-local", + "attributions": [ + "import", + "global", + "inferred" + ], + "addressSpaces": [ + "post", + "get", + "cookie", + "files", + "server", + "route-param", + "unknown" + ] +} diff --git a/package.json b/package.json index 7d99ec6..839f4a5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "typecheck": "tsc --noEmit && node scripts/typecheck-templates.mjs", "typecheck:templates": "node scripts/typecheck-templates.mjs", "prepare": "npm run build", - "prepublishOnly": "npm run typecheck && npm test && npm run build" + "prepublishOnly": "npm run typecheck && npm test && npm run build", + "capabilities": "node scripts/emit-capabilities.mjs", + "capabilities:check": "node scripts/emit-capabilities.mjs --check && node scripts/check-capability-version.mjs" }, "engines": { "node": ">=18" diff --git a/scripts/check-capability-version.mjs b/scripts/check-capability-version.mjs new file mode 100644 index 0000000..10b2d91 --- /dev/null +++ b/scripts/check-capability-version.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// Fails when the capability vocabulary changed without a matching version bump. +// +// The version is what makes vendoring safe: a consumer records which version it copied, and decides +// whether it must re-read the contract. That only works if the number actually moves — and nothing so far +// made it. Adding a member, regenerating the manifest and leaving the version alone passed every existing +// check, which is the same silent-drift failure the manifest was introduced to remove, one level up. +// +// Classification: +// breaking a member removed or renamed, a field removed, or a scalar changed → MAJOR +// (a consumer pinned to the old list keeps emitting a value that can no longer match) +// additive a member or field added → MINOR or MAJOR +// none no vocabulary change → no requirement +// +// Usage: node scripts/check-capability-version.mjs [--base ] (default: origin/main) +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const FILE = 'capabilities.json'; +const argBase = process.argv.indexOf('--base'); +const base = argBase !== -1 ? process.argv[argBase + 1] : process.env.BASE_REF || 'origin/main'; + +const head = JSON.parse(readFileSync(join(root, FILE), 'utf8')); + +let before; +try { + before = JSON.parse(execFileSync('git', ['show', `${base}:${FILE}`], { cwd: root, encoding: 'utf8' })); +} catch { + // Absent at the base: this commit introduces the contract, so there is nothing to bump from. + console.log(`${FILE} does not exist at ${base} — contract is new, no bump required.`); + process.exit(0); +} + +const semver = (v) => { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v ?? ''); + if (!m) throw new Error(`version ${JSON.stringify(v)} is not semver`); + return { major: +m[1], minor: +m[2], patch: +m[3] }; +}; + +const vocabularies = (doc) => + Object.entries(doc).filter(([k, v]) => k !== 'version' && k !== '$comment'); + +const added = []; +const removed = []; + +const beforeMap = new Map(vocabularies(before)); +const headMap = new Map(vocabularies(head)); + +for (const [key, value] of headMap) { + if (!beforeMap.has(key)) { + added.push(`field ${key}`); + continue; + } + const was = beforeMap.get(key); + if (Array.isArray(value) && Array.isArray(was)) { + for (const m of value) if (!was.includes(m)) added.push(`${key}.${m}`); + for (const m of was) if (!value.includes(m)) removed.push(`${key}.${m}`); + } else if (value !== was) { + // A scalar change (e.g. which tier may auto-promote) changes the MEANING of the contract for every + // consumer, so it is breaking even though nothing was removed from a list. + removed.push(`${key}: ${JSON.stringify(was)} → ${JSON.stringify(value)}`); + } +} +for (const key of beforeMap.keys()) if (!headMap.has(key)) removed.push(`field ${key}`); + +const from = semver(before.version); +const to = semver(head.version); +const bump = to.major > from.major ? 'major' : to.minor > from.minor ? 'minor' : to.patch > from.patch ? 'patch' : 'none'; + +const describe = () => { + const parts = []; + if (added.length) parts.push(`added: ${added.join(', ')}`); + if (removed.length) parts.push(`removed/changed: ${removed.join(', ')}`); + return parts.join(' | '); +}; + +const fail = (msg) => { + console.error(`\n${FILE}: ${msg}`); + console.error(` base ${base} = ${before.version}, head = ${head.version}`); + console.error(` ${describe()}`); + console.error(`\n Bump CAPABILITY_VERSION in src/map/capabilities.ts, then \`npm run capabilities\`.`); + process.exit(1); +}; + +if (removed.length > 0) { + if (to.major <= from.major) { + fail('the vocabulary lost or redefined a member, which is BREAKING — a consumer pinned to the ' + + 'old list keeps emitting a value that can no longer match. A major bump is required.'); + } + console.log(`${FILE}: breaking change with a major bump (${before.version} → ${head.version}). ${describe()}`); +} else if (added.length > 0) { + if (bump === 'none' || bump === 'patch') { + fail('the vocabulary gained a member, so consumers that vendored the old copy are now behind. ' + + 'A minor bump (or major) is required.'); + } + console.log(`${FILE}: additive change with a ${bump} bump (${before.version} → ${head.version}). ${describe()}`); +} else { + console.log(`${FILE}: no vocabulary change (version ${head.version}).`); +} diff --git a/scripts/emit-capabilities.mjs b/scripts/emit-capabilities.mjs new file mode 100644 index 0000000..c75f441 --- /dev/null +++ b/scripts/emit-capabilities.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Writes `capabilities.json` from the TypeScript definition in src/map/capabilities.ts. +// +// The JSON is what the other layers vendor: the rule-authoring toolchain and the platform that binds a +// coordinate into a rule. Neither can import TypeScript, and hand-copying the lists is the drift this +// file exists to prevent — a member added here and missed there produces a value that can never match, +// with nothing to notice it. +// +// Not generated at build time on purpose: it is committed, so a change to the vocabulary shows up as a +// reviewable diff in the contract rather than appearing silently in dist. `tests/map/capabilities.test.ts` +// fails if the committed file drifts from the source. +// +// Deliberately NOT in package.json `files`: the consumers vendor this file from source, and the published +// package's contents are a reviewed surface of their own (see +// tests/pack-safety.test.ts). Vendoring is what makes the version field load-bearing — a consumer records +// which version it copied. +import { writeFileSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const src = readFileSync(join(root, 'src/map/capabilities.ts'), 'utf8'); + +// Parsed rather than imported so this script needs no build step and no TypeScript loader. +const arrayOf = (name) => { + const m = new RegExp(`export const ${name} = \\[([^\\]]*)\\] as const;`, 's').exec(src); + if (!m) throw new Error(`could not find ${name} in src/map/capabilities.ts`); + return [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]); +}; +const stringOf = (name) => { + const m = new RegExp(`export const ${name} = '([^']+)';`).exec(src); + if (!m) throw new Error(`could not find ${name}`); + return m[1]; +}; + +const manifest = { + $comment: + 'GENERATED from src/map/capabilities.ts by scripts/emit-capabilities.mjs — do not edit by hand. ' + + 'The single versioned definition of what the input-flow map can describe, vendored by the ' + + 'reachability recipe schema/validator and by the server that binds coordinates into rules.', + version: stringOf('CAPABILITY_VERSION'), + sinkKinds: arrayOf('SINK_KINDS'), + argumentRoles: arrayOf('ARGUMENT_ROLES'), + candidateFamilies: arrayOf('CANDIDATE_FAMILIES'), + confidenceTiers: arrayOf('CONFIDENCE_TIERS'), + provenConfidenceTiers: arrayOf('PROVEN_CONFIDENCE_TIERS'), + autoPromotableConfidence: stringOf('AUTO_PROMOTABLE_CONFIDENCE'), + attributions: arrayOf('ATTRIBUTIONS'), + addressSpaces: arrayOf('ADDRESS_SPACES'), +}; + +const out = join(root, 'capabilities.json'); +const text = JSON.stringify(manifest, null, 2) + '\n'; +if (process.argv.includes('--check')) { + const current = readFileSync(out, 'utf8'); + if (current !== text) { + console.error('capabilities.json is stale — run `npm run capabilities` and commit the result.'); + process.exit(1); + } + console.log('capabilities.json is up to date.'); +} else { + writeFileSync(out, text); + console.log(`wrote ${out} (version ${manifest.version})`); +} diff --git a/src/map/capabilities.ts b/src/map/capabilities.ts new file mode 100644 index 0000000..275bfdb --- /dev/null +++ b/src/map/capabilities.ts @@ -0,0 +1,178 @@ +// The CAPABILITY CONTRACT: the closed vocabularies that describe what the map can see. +// +// These lists are consumed by layers that ship separately — this extractor, the rule-authoring toolchain, +// and the platform that binds a coordinate into a rule. Each used to keep its own copy, which is a drift +// problem with a silent failure mode: a sink kind added here and missed by an authoring layer makes every +// detector naming it unauthorable, and one missed by the platform makes every flow of that kind unusable. +// Nothing errors — the capability simply never matches, which reads exactly like "not reachable". +// +// So there is ONE definition, here, and it is versioned. The TypeScript unions are derived from these +// arrays (not declared alongside them, which would be a fourth copy), `capabilities.json` is generated +// from them for the other repos to vendor and assert against, and a test fails if the two disagree. +// +// Adding a capability is deliberately a contract change: bump `CAPABILITY_VERSION`, regenerate the +// manifest, and update every layer that vendors it. A new sink family also owes adversarial corpus +// cases before it may generate rules — one capability admits a whole package family at once, so a wrong +// argument-role table mis-pins all of them. + +/** + * Version of this vocabulary. Additive changes (a new member) bump the MINOR; removing or renaming a + * member is breaking and bumps the MAJOR, because a consumer pinned to the old list will keep emitting a + * value that can no longer match. + */ +export const CAPABILITY_VERSION = '1.0.0'; + +/** Sink families the extractor recognizes. A dangerous OPERATION, not a package. */ +export const SINK_KINDS = ['db', 'fs', 'http', 'exec', 'eval'] as const; + +/** + * Which argument of a sink call received the tainted value. This decides which mitigation class is even + * applicable — `command` vs `args`, `path` vs `content`, `sql` vs `values` — so a consumer that cannot + * read it cannot compile a rule. + */ +export const ARGUMENT_ROLES = [ + 'command', 'file', 'args', + 'url', 'init', 'body', 'options', + 'path', 'content', + 'sql', 'values', 'columns', 'column', 'value', + 'code', 'unknown', +] as const; + +/** + * The mitigation classes a flow can support. Deliberately narrower than the roles: only patterns where a + * request value reaching that argument is inherently dangerous AND a rule can express it. + */ +export const CANDIDATE_FAMILIES = ['ssrf', 'command-injection', 'path-traversal', 'sql-injection', 'code-injection'] as const; + +/** + * Flow confidence tiers, strongest first. Only the first two are *proven*; the rest are distinct kinds of + * not-knowing rather than weaker degrees of the same thing, which is why consumers must treat this as a + * set membership test and never as an ordering comparison. + */ +export const CONFIDENCE_TIERS = ['exact-local', 'transformed-local', 'imported', 'heuristic', 'unknown'] as const; + +/** The tiers that assert the input actually reaches the sink — the only ones a rule may be pinned from. */ +export const PROVEN_CONFIDENCE_TIERS = ['exact-local', 'transformed-local'] as const; + +/** The single tier eligible for automatic promotion to blocking (subject to the server's own gates). */ +export const AUTO_PROMOTABLE_CONFIDENCE = 'exact-local'; + +/** How a sink's package was established. Absent attribution is deliberately not a member: see `Sink`. */ +export const ATTRIBUTIONS = ['import', 'global', 'inferred'] as const; + +/** The request address spaces an input can live in — half of an input's identity. */ +export const ADDRESS_SPACES = ['post', 'get', 'cookie', 'files', 'server', 'route-param', 'unknown'] as const; + +/** The whole contract, as the other repos consume it. Key order is stable so the JSON is diffable. */ +export const CAPABILITY_MANIFEST = { + version: CAPABILITY_VERSION, + sinkKinds: SINK_KINDS, + argumentRoles: ARGUMENT_ROLES, + candidateFamilies: CANDIDATE_FAMILIES, + confidenceTiers: CONFIDENCE_TIERS, + provenConfidenceTiers: PROVEN_CONFIDENCE_TIERS, + autoPromotableConfidence: AUTO_PROMOTABLE_CONFIDENCE, + attributions: ATTRIBUTIONS, + addressSpaces: ADDRESS_SPACES, +} as const; + +/** + * What a declared capability owes before it counts as supported. + * + * Declaring a member of `SINK_KINDS` is a claim that the extractor recognizes that operation. Nothing in + * the vocabulary enforces it: a kind can be added, the manifest regenerated, and every consumer taught to + * accept a capability that no recognizer ever emits — a vocabulary entry with no behaviour behind it, and + * a rule family that can never fire. The corpus does not catch it either, because a fixture that exercises + * the OTHER kinds still passes a "we emit some sinks" assertion. + * + * So each kind names its own control: the minimal shape that must produce a sink of exactly that kind. + * `tests/map/capabilities.test.ts` runs every control and fails on the one that produces nothing, and the + * `Record` type means adding a kind without a control is a TYPE error rather than a missing + * test — the compiler asks the question before CI does. + */ +/** The shape shared by every control, whatever it is allowed to compile. */ +interface ControlBase { + /** + * Handler body for the control fixture: one statement that must yield a sink of this kind, written the + * way an app really would. `req.body.` is the tainted input. + */ + control: string; + /** Imports the control needs, and the packages they come from. */ + setup: string; + /** Dependencies the fixture's package.json must declare. */ + deps: string[]; +} + +/** + * A capability whose flows can compile a rule owes three exact claims, not just "something generatable". + * + * `expectRole` and `expectFamily` are asserted for EQUALITY. A control that merely produces "some + * candidate with some family" would accept a recognizer that classified an archive-extraction flow as + * command injection — a rule that inspects the wrong thing and blocks the wrong traffic while the test + * stays green. + * + * `adversarialCaseId` names the corpus case that proves the recognizer does not fire on code which merely + * RESEMBLES this API. It is an id rather than a search term because coverage has to be a link between two + * declarations: a grep for an API name is satisfied by a comment. + */ +interface GeneratableControl extends ControlBase { + ruleGeneratable: true; + expectRole: (typeof ARGUMENT_ROLES)[number]; + expectFamily: (typeof CANDIDATE_FAMILIES)[number]; + adversarialCaseId: string; +} + +/** A capability the map can see but never turns into a rule — reported for review only. */ +interface ObservableControl extends ControlBase { + ruleGeneratable: false; +} + +export type CapabilityControl = GeneratableControl | ObservableControl; + +export const CAPABILITY_CONTROLS: Record<(typeof SINK_KINDS)[number], CapabilityControl> = { + db: { + setup: 'import { Pool } from "pg";\nconst pool = new Pool();', + control: 'pool.query(req.body.sql);', + deps: ['pg'], + ruleGeneratable: true, + expectRole: 'sql', + expectFamily: 'sql-injection', + adversarialCaseId: 'adv/inferred-db-receivers', + }, + fs: { + setup: 'import fs from "node:fs";', + control: 'fs.readFileSync(req.body.path);', + deps: [], + ruleGeneratable: true, + expectRole: 'path', + expectFamily: 'path-traversal', + adversarialCaseId: 'adv/lookalike-exports', + }, + http: { + setup: '', + control: 'fetch(req.body.url);', + deps: [], + ruleGeneratable: true, + expectRole: 'url', + expectFamily: 'ssrf', + adversarialCaseId: 'adv/shadowed-globals', + }, + exec: { + setup: 'import { exec } from "node:child_process";', + control: 'exec(req.body.cmd);', + deps: [], + ruleGeneratable: true, + expectRole: 'command', + expectFamily: 'command-injection', + adversarialCaseId: 'adv/lookalike-exports', + }, + eval: { + setup: '', + control: 'eval(req.body.code);', + deps: [], + ruleGeneratable: true, + expectRole: 'code', + expectFamily: 'code-injection', + adversarialCaseId: 'adv/local-eval-lookalikes', + }, +}; diff --git a/src/map/types.ts b/src/map/types.ts index 25f4ffc..c19d151 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -1,3 +1,13 @@ +// The closed vocabularies below are derived from `capabilities.ts` — the single versioned definition +// shared with the rule-authoring and rule-binding layers. Declaring a union here too would be a copy, +// and the failure mode of the two disagreeing is silent (a value that can never match). +import { + ADDRESS_SPACES, + ARGUMENT_ROLES, + CANDIDATE_FAMILIES, + SINK_KINDS, +} from './capabilities.js'; + // The build-time input-flow ("attack surface") map. connect's `map` command walks the app's source // and emits this per-site: entry points → the inputs each reads → the sinks/dependencies they reach. // It's both a user-facing surface view and the coordinate source dynamic vPatch templates bind against. @@ -16,7 +26,7 @@ export type InputSource = * half of an input's identity: `query.id` and `body.id` are different inputs that happen to share a * name, and keying by name alone let a rule be pinned to the wrong one. */ -export type AddressSpace = 'post' | 'get' | 'cookie' | 'files' | 'server' | 'route-param' | 'unknown'; +export type AddressSpace = (typeof ADDRESS_SPACES)[number]; /** * A field's declared shape BEFORE it is placed in the request: validator extraction knows a name, a type @@ -63,7 +73,7 @@ export interface InputField { /** Sink families the extractor recognizes today. Kept as a closed union so a consumer can exhaustively * switch on it; add a member here when a recognizer is added. */ -export type SinkKind = 'db' | 'fs' | 'http' | 'exec' | 'eval'; +export type SinkKind = (typeof SINK_KINDS)[number]; export interface Sink { kind: SinkKind; @@ -194,12 +204,7 @@ export interface Endpoint { * even applicable, so a candidate compiler cannot work without it: `command` vs `args` for exec, * `url` vs `body` for http, `path` vs `content` for the filesystem, `sql` vs `values` for a database. */ -export type ArgumentRole = - | 'command' | 'file' | 'args' - | 'url' | 'init' | 'body' | 'options' - | 'path' | 'content' - | 'sql' | 'values' | 'columns' | 'column' | 'value' - | 'code' | 'unknown'; +export type ArgumentRole = (typeof ARGUMENT_ROLES)[number]; /** * The mitigation class a flow could support. Deliberately narrow: only patterns where a request value @@ -207,7 +212,7 @@ export type ArgumentRole = * generic database *values* is real reachability signal but NOT a blockable pattern on its own, so it * gets no family. */ -export type CandidateFamily = 'ssrf' | 'command-injection' | 'path-traversal' | 'sql-injection' | 'code-injection'; +export type CandidateFamily = (typeof CANDIDATE_FAMILIES)[number]; export interface Flow { /** Which argument of the sink call received the value (see ArgumentRole). */ diff --git a/tests/map/capabilities.test.ts b/tests/map/capabilities.test.ts new file mode 100644 index 0000000..99e40e3 --- /dev/null +++ b/tests/map/capabilities.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { + ADDRESS_SPACES, + CAPABILITY_CONTROLS, + ARGUMENT_ROLES, + CANDIDATE_FAMILIES, + CAPABILITY_MANIFEST, + CAPABILITY_VERSION, + CONFIDENCE_TIERS, + PROVEN_CONFIDENCE_TIERS, + SINK_KINDS, +} from '../../src/map/capabilities.js'; +import { buildInputMap } from '../../src/map/index.js'; +import { ADVERSARIAL } from './corpus-cases.js'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +// The capability vocabulary is consumed by three separately-shipped layers: this extractor, the +// reachability recipe schema + validator, and the server that binds a coordinate into a rule. Drift +// between them fails SILENTLY — a sink kind this side knows and the recipe schema does not makes the +// capability unauthorable; one the server does not know makes every flow of that kind unusable. Nothing +// throws; the capability just never matches, which reads exactly like "not reachable". +// +// So these tests defend the contract itself: one definition, a committed manifest that cannot drift from +// it, and a version that has to move when the vocabulary does. +const root = join(import.meta.dirname, '..', '..'); +const manifestPath = join(root, 'capabilities.json'); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + +describe('the committed manifest matches the source of truth', () => { + it('is byte-identical to what the emitter produces', () => { + // Would catch a hand-edit of capabilities.json, or a member added to the TS without regenerating. + const out = execFileSync('node', [join(root, 'scripts/emit-capabilities.mjs'), '--check'], { + cwd: root, + encoding: 'utf8', + }); + expect(out).toContain('up to date'); + }); + + it('agrees with the TypeScript definition member for member', () => { + expect(manifest.version).toBe(CAPABILITY_VERSION); + expect(manifest.sinkKinds).toEqual([...SINK_KINDS]); + expect(manifest.argumentRoles).toEqual([...ARGUMENT_ROLES]); + expect(manifest.candidateFamilies).toEqual([...CANDIDATE_FAMILIES]); + expect(manifest.confidenceTiers).toEqual([...CONFIDENCE_TIERS]); + expect(manifest.provenConfidenceTiers).toEqual([...PROVEN_CONFIDENCE_TIERS]); + expect(manifest.addressSpaces).toEqual([...ADDRESS_SPACES]); + }); + + it('carries a semver version, so a consumer can pin and detect a break', () => { + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); + +describe('the contract is internally consistent', () => { + it('every proven tier is a real tier, in strongest-first order', () => { + for (const tier of PROVEN_CONFIDENCE_TIERS) expect(CONFIDENCE_TIERS).toContain(tier); + expect(CONFIDENCE_TIERS[0]).toBe('exact-local'); + expect(CONFIDENCE_TIERS[1]).toBe('transformed-local'); + }); + + it('the auto-promotable tier is the single strongest proven tier', () => { + // If this ever admits a second tier, promotion policy on the server changes meaning — the + // constant is the contract, not a default someone may widen locally. + expect(CAPABILITY_MANIFEST.autoPromotableConfidence).toBe('exact-local'); + expect(PROVEN_CONFIDENCE_TIERS).toContain(CAPABILITY_MANIFEST.autoPromotableConfidence); + }); + + it('candidate families are a subset of what the roles can support', () => { + // Not a mechanical check of the mapping (that lives in sinks.ts) but of the vocabulary's shape: + // every family must be expressible, i.e. narrower than the role list. + expect(CANDIDATE_FAMILIES.length).toBeLessThan(ARGUMENT_ROLES.length); + }); + + it('has no duplicate members in any vocabulary', () => { + for (const [name, list] of Object.entries(manifest)) { + if (!Array.isArray(list)) continue; + expect(new Set(list).size, `${name} has duplicates`).toBe(list.length); + } + }); +}); + +describe('what the map emits stays inside the contract', () => { + it('never produces a sink kind, role, family or tier outside the vocabulary', async () => { + const d = mkdtempSync(join(tmpdir(), 'ps-cap-')); + mkdirSync(join(d, 'src'), { recursive: true }); + writeFileSync(join(d, 'package.json'), JSON.stringify({ dependencies: { express: '4', pg: '8' } })); + writeFileSync(join(d, 'src', 'app.ts'), ` + import express from "express"; + import { Pool } from "pg"; + import fs from "node:fs"; + import { exec } from "node:child_process"; + const pool = new Pool(); + const app = express(); + app.post("/q", (req, res) => { pool.query(req.body.sql); res.end("ok"); }); + app.post("/f", (req, res) => { fs.readFileSync(req.body.path); res.end("ok"); }); + app.post("/c", (req, res) => { exec(req.body.cmd); res.end("ok"); }); + app.post("/u", (req, res) => { fetch(req.body.url); res.end("ok"); }); + `); + const { map } = await buildInputMap(d); + const sinkKinds = new Set(); + const roles = new Set(); + const families = new Set(); + const tiers = new Set(); + for (const ep of map!.endpoints) { + for (const s of ep.sinks) sinkKinds.add(s.kind); + for (const f of ep.flows) { + tiers.add(f.confidence); + if (f.argumentRole) roles.add(f.argumentRole); + if (f.candidateFamily) families.add(f.candidateFamily); + } + } + // Non-vacuity first: a fixture that produced nothing would satisfy every assertion below. + expect(sinkKinds.size).toBeGreaterThan(2); + expect(families.size).toBeGreaterThan(0); + for (const k of sinkKinds) expect(SINK_KINDS as readonly string[]).toContain(k); + for (const r of roles) expect(ARGUMENT_ROLES as readonly string[]).toContain(r); + for (const f of families) expect(CANDIDATE_FAMILIES as readonly string[]).toContain(f); + for (const t of tiers) expect(CONFIDENCE_TIERS as readonly string[]).toContain(t); + // And the import inventory's recognizedSinkKinds draw from the same vocabulary. + for (const dep of map!.imports ?? []) { + for (const k of dep.recognizedSinkKinds) expect(SINK_KINDS as readonly string[]).toContain(k); + } + rmSync(d, { recursive: true, force: true }); + }); +}); + +// A declared capability with no recognizer behind it is a silent hole: the vocabulary accepts it, every +// consumer accepts it, and no flow of that kind is ever emitted — so the rule family it unlocks can never +// fire. The existing "everything emitted falls inside the vocabulary" test cannot see it, because it only +// proves the kinds that DO emit are legal. This is the other direction: every kind we declare must be +// emitted by its own control. +describe('every declared capability has a recognizer behind it', () => { + const build = async (kind: string, ctl: (typeof CAPABILITY_CONTROLS)[keyof typeof CAPABILITY_CONTROLS]) => { + const d = mkdtempSync(join(tmpdir(), `ps-ctl-${kind}-`)); + mkdirSync(join(d, 'src'), { recursive: true }); + const deps: Record = { express: '4' }; + for (const dep of ctl.deps) deps[dep] = '*'; + writeFileSync(join(d, 'package.json'), JSON.stringify({ dependencies: deps })); + writeFileSync(join(d, 'src', 'app.ts'), [ + 'import express from "express";', + ctl.setup, + 'const app = express();', + `app.post("/${kind}", (req, res) => { ${ctl.control} res.end("ok"); });`, + ].join('\n')); + const { map } = await buildInputMap(d); + rmSync(d, { recursive: true, force: true }); + return map!; + }; + + it('names a control for every sink kind — enforced by the type, verified here', () => { + // Record already makes a missing control a compile error; this asserts the runtime + // object matches too, so a cast or a merge accident cannot slip past. + expect(Object.keys(CAPABILITY_CONTROLS).sort()).toEqual([...SINK_KINDS].sort()); + }); + + for (const kind of SINK_KINDS) { + const ctl = CAPABILITY_CONTROLS[kind]; + + it(`emits a '${kind}' sink from its own control fixture`, async () => { + const map = await build(kind, ctl); + const kinds = map.endpoints.flatMap((e) => e.sinks.map((s) => s.kind)); + expect(kinds, `control for '${kind}' produced ${JSON.stringify(kinds)}`).toContain(kind); + }); + + if (ctl.ruleGeneratable) { + it(`compiles a candidate from a proven '${kind}' flow`, async () => { + const map = await build(kind, ctl); + const flows = map.endpoints.flatMap((e) => e.flows).filter((f) => f.sink.kind === kind); + expect(flows.length, `no flow reached the '${kind}' sink`).toBeGreaterThan(0); + const generatable = flows.filter((f) => f.ruleGeneratable); + expect(generatable.length, `'${kind}' is declared rule-generatable but compiled nothing`).toBeGreaterThan(0); + // EQUALITY, not membership. "Some candidate with some family" would accept a recognizer that + // classified this flow as the wrong mitigation class — a rule inspecting the wrong thing, and + // blocking the wrong traffic, with the suite still green. + for (const f of generatable) { + expect(f.argumentRole, `'${kind}' candidate role`).toBe(ctl.expectRole); + expect(f.candidateFamily, `'${kind}' candidate family`).toBe(ctl.expectFamily); + } + }); + } + } + + it('names an existing adversarial case for every rule-generatable capability', () => { + // The link is declaration-to-declaration: a named id that must resolve to a real case. The previous + // version searched the corpus file for the API name, which a comment or an unrelated positive fixture + // satisfied just as well — coverage that could be true by coincidence. + const byId = new Map(ADVERSARIAL.map((c) => [c.id, c])); + for (const kind of SINK_KINDS) { + const ctl = CAPABILITY_CONTROLS[kind]; + if (!ctl.ruleGeneratable) continue; + const c = byId.get(ctl.adversarialCaseId); + expect(c, `'${kind}' names adversarial case '${ctl.adversarialCaseId}', which does not exist`).toBeDefined(); + expect(c!.kind, `'${ctl.adversarialCaseId}' is not in the adversarial category`).toBe('adversarial'); + // An adversarial case that expects candidates cannot be evidence that a lookalike compiles nothing. + expect(c!.expectCandidates, `'${ctl.adversarialCaseId}' expects candidates`).toEqual([]); + } + }); + + it('and that case really compiles nothing while still showing a surface', async () => { + // The declaration says it expects no candidates; this builds it and checks the extractor agrees — + // plus the corpus non-vacuity rule, since a case that detects nothing at all would satisfy a + // zero-candidate assertion for the wrong reason. + const ids = new Set( + SINK_KINDS.map((k) => CAPABILITY_CONTROLS[k]).filter((c) => c.ruleGeneratable) + .map((c) => (c as Extract).adversarialCaseId), + ); + for (const id of ids) { + const c = ADVERSARIAL.find((x) => x.id === id)!; + const d = mkdtempSync(join(tmpdir(), 'ps-adv-')); + for (const [rel, body] of Object.entries(c.files)) { + mkdirSync(join(d, rel, '..'), { recursive: true }); + writeFileSync(join(d, rel), body); + } + writeFileSync(join(d, 'package.json'), JSON.stringify(c.pkg)); + const { map } = await buildInputMap(d); + const candidates = map!.endpoints.flatMap((e) => e.flows).filter((f) => f.ruleGeneratable); + expect(candidates.map((f) => f.candidateFamily), `${id} compiled a candidate`).toEqual([]); + const inputs = map!.endpoints.flatMap((e) => e.inputs); + expect(inputs.length, `${id} detected no surface at all — the zero-candidate result is vacuous`) + .toBeGreaterThan(0); + rmSync(d, { recursive: true, force: true }); + } + }, 60_000); + +}); diff --git a/tests/map/corpus-cases.ts b/tests/map/corpus-cases.ts new file mode 100644 index 0000000..4e64ae7 --- /dev/null +++ b/tests/map/corpus-cases.ts @@ -0,0 +1,373 @@ +// The golden corpus cases, extracted so they can be referenced from outside the corpus test. +// +// `tests/map/capabilities.test.ts` asserts that every rule-generatable capability names an adversarial +// case that covers it. That check used to grep this file for an API name, which any comment or unrelated +// fixture could satisfy — coverage has to be a structural link between two declarations, so the cases +// carry stable ids and the capability contract references them. +// +// `kind` is documented on the interface: `stack` measures recall on shapes builders really generate, +// `adversarial` is app code CONSTRUCTED to look dangerous and must compile nothing. + +export interface Case { + /** + * Stable identity, referenced from outside this file — `CAPABILITY_CONTROLS` names the adversarial case + * that covers each rule-generatable capability by this id. Renaming one breaks that reference loudly, + * which is the point: coverage is a link between two declarations, not a string that happens to appear + * in the file. + */ + id: string; + name: string; + /** + * `stack`: a project shape a builder really generates — measures recall and correct pinning. + * `adversarial`: app code CONSTRUCTED to look dangerous. A permanent category, not a bag of + * regressions: every false-candidate class we have found came from code that merely resembled a + * dangerous API, so the corpus has to contain lookalikes on purpose. These cases must produce a + * visible surface (inputs, usually sinks) and still compile no rule. + */ + kind?: 'stack' | 'adversarial'; + pkg: Record; + files: Record; + /** `family @ runtimeParameter` for every flow that SHOULD compile to a candidate. */ + expectCandidates: string[]; + /** Proven flows that must NOT be candidates, as `input -> reason-fragment`. */ + expectRefused?: Array<[string, RegExp]>; +} + +export const ADVERSARIAL: Case[] = [ + { + id: 'adv/lookalike-exports', + name: 'adversarial: app code whose exports collide with dangerous API names', + kind: 'adversarial', + pkg: { dependencies: { express: '4' } }, + files: { + 'src/util.ts': ` + export function exec(x) { return x.length } + export function query(x) { return x } + export function readFileSync(p) { return p } + export function fetch(u) { return { u } } + `, + 'src/server.ts': ` + import * as helper from "./util"; + import { fetch, exec } from "./util"; + import express from "express"; + const app = express(); + // Namespace member calls AND named imports, both from a relative module. + app.post("/ns", (req, res) => { + helper.exec(req.body.cmd); helper.query(req.body.sql); helper.readFileSync(req.body.path); + res.end(); + }); + app.post("/named", (req, res) => { fetch(req.body.url); exec(req.body.cmd2); res.end(); }); + `, + }, + expectCandidates: [], + }, + { + id: 'adv/inferred-db-receivers', + name: 'adversarial: untraceable receivers in a file that imports real db clients', + kind: 'adversarial', + pkg: { dependencies: { express: '4', pg: '8', '@supabase/supabase-js': '2' } }, + files: { + 'src/server.ts': ` + import { Pool } from "pg"; + import { createClient } from "@supabase/supabase-js"; + import express from "express"; + const app = express(); + // The package is only INFERRED from the file's imports; the receivers are app objects. + app.post("/raw", (req, res) => { res.locals.db.query(req.body.sql); res.end(); }); + app.post("/from", (req, res) => { res.locals.sb.from("t").insert({ v: req.body.v }); res.end(); }); + `, + }, + expectCandidates: [], + expectRefused: [['sql', /inferred from the file's other imports/]], + }, + { + id: 'adv/local-eval-lookalikes', + name: 'adversarial: a locally-declared Function and a member .eval() are not code injection', + kind: 'adversarial', + pkg: { dependencies: { express: '4' } }, + files: { + 'src/server.ts': ` + import express from "express"; + const app = express(); + // A local declaration wins over the global: this Function is app code, not the compiler entry. + function Function(src) { return { src } } + app.post("/localfn", (req, res) => { Function(req.body.code); res.end(); }); + // A dangerous METHOD NAME on a receiver we cannot trace is not a dangerous API. + app.post("/member", (req, res) => { res.locals.vm.eval(req.body.payload); res.end(); }); + `, + }, + expectCandidates: [], + }, + { + id: 'adv/shadowed-globals', + name: 'adversarial: parameters shadowing dangerous globals', + kind: 'adversarial', + pkg: { dependencies: { express: '4' } }, + files: { + 'src/server.ts': ` + import express from "express"; + const app = express(); + app.post("/shadow", (req, res) => { + const send = (fetch) => fetch(req.body.url); + send((u) => ({ u })); + const run = (eval2) => eval2(req.body.code); + run((c) => c); + res.end(); + }); + `, + }, + expectCandidates: [], + }, + { + id: 'adv/same-name-two-namespaces', + name: 'adversarial: one field name read from two request namespaces, addressed separately', + kind: 'adversarial', + pkg: { dependencies: { express: '4' } }, + files: { + // `params.id` and `query.id` share a NAME but not an address, so they are two inputs. Name-keyed + // extraction kept one of them and let its coordinate stand for both, which pinned a rule to + // `get.id` for data arriving in the path segment. Now each is addressed on its own: the query read + // earns `get.id`, the route param earns nothing (the resolver cannot reach it). Both source orders + // are covered because the order dependence is what made it a bug. + 'src/a.ts': ` + import express from "express"; + import fs from "node:fs"; + const app = express(); + app.get("/qp/:id", ({ params: p, query: q }, res) => { fs.readFileSync(q.id); fs.readFileSync(p.id); res.end(); }); + `, + 'src/b.ts': ` + import express from "express"; + import fs from "node:fs"; + const app = express(); + app.get("/pq/:id", ({ params: p, query: q }, res) => { fs.readFileSync(p.id); fs.readFileSync(q.id); res.end(); }); + `, + }, + expectCandidates: ['path-traversal @ get.id', 'path-traversal @ get.id'], + expectRefused: [['id', /route parameters are not exposed/]], + }, + { + id: 'adv/validator-vs-sink-namespace', + name: 'adversarial: a validator field and the sink read address different namespaces', + kind: 'adversarial', + pkg: { dependencies: { express: '4', zod: '3' } }, + files: { + // The schema describes the BODY; the sink consumes the QUERY. Both are called `id`, and grouping + // inputs by name made the schema's `post.id` the only surviving entry — so the candidate pinned a + // parameter the payload never travels in. As separate identities the query read is pinned correctly + // and the declared-but-unread body field simply has no proven flow. + 'src/server.ts': ` + import express from "express"; + import fs from "node:fs"; + import { z } from "zod"; + const app = express(); + app.post("/mismatch", (req, res) => { + z.object({ id: z.string() }).parse(req.body); + res.end(fs.readFileSync(req.query.id)); + }); + app.post("/agree", (req, res) => { + z.object({ doc: z.string() }).parse(req.body); + res.end(fs.readFileSync(req.body.doc)); + }); + `, + }, + // `/agree` must still compile: a validated body field read by the sink is the common good case. + expectCandidates: ['path-traversal @ get.id', 'path-traversal @ post.doc'], + // The schema's `post:id` is declared but never read by the sink — proven-nothing, not blockable. + expectRefused: [['id', /no proven local read/]], + }, + { + id: 'adv/traced-package-wrong-api', + name: 'adversarial: a traced package that does not establish the API', + kind: 'adversarial', + pkg: { dependencies: { express: '4', '@apollo/client': '3' } }, + files: { + // `.query()` is a generic method name. An ApolloClient instance resolves to a REAL dependency, so + // attribution alone admits it — and a GraphQL call became a precise SQL-injection candidate. Package + // provenance is not API provenance. + 'src/lib/gql.ts': ` + import { ApolloClient } from "@apollo/client"; + export const client = new ApolloClient({ uri: "https://api.example.com" }); + `, + 'src/server.ts': ` + import express from "express"; + import { client } from "./lib/gql"; + const app = express(); + app.post("/graphql", async (req, res) => { await client.query(req.body.sql); res.end(); }); + `, + }, + expectCandidates: [], + expectRefused: [['sql', /does not establish a db API/]], + }, + { + id: 'adv/sibling-expressions', + name: 'adversarial: sibling expressions must not contaminate each other', + kind: 'adversarial', + pkg: { dependencies: { express: '4' } }, + files: { + 'src/server.ts': ` + import express from "express"; + import fs from "node:fs"; + import { exec } from "node:child_process"; + const STATIC = "ls -la"; + const app = express(); + // Only ONE pairing is real: path -> readFileSync. \`label\` reaches no sink, and the exec call + // takes no request data at all — an inventory-level "both present" must not become a flow. + app.post("/two", (req, res) => { + const label = req.body.label; + fs.readFileSync(req.body.path); + exec(STATIC); + res.end(label); + }); + `, + }, + expectCandidates: ['path-traversal @ post.path'], + }, +]; + +export const CASES: Case[] = [ + { + id: 'stack/tanstack-supabase', + name: 'lovable / tanstack start + supabase (server fns, validated payload)', + pkg: { dependencies: { '@tanstack/react-start': '1', zod: '3', '@supabase/supabase-js': '2' } }, + files: { + 'src/lib/tasks.functions.ts': ` + import { createServerFn } from "@tanstack/react-start"; + import { z } from "zod"; + import { createClient } from "@supabase/supabase-js"; + function getClient() { return createClient(process.env.URL, process.env.KEY); } + export const createTask = createServerFn({ method: "POST" }) + .inputValidator((i) => z.object({ title: z.string().min(1).max(200) }).parse(i)) + .handler(async ({ data }) => { await getClient().from("tasks").insert({ title: data.title }); }); + `, + }, + // A request value in a parameterized insert is reachability signal, not a blockable pattern. + expectCandidates: [], + expectRefused: [['title', /not a blockable pattern/]], + }, + { + id: 'stack/express-axios-fs-exec', + name: 'express + axios + fs + child_process (the high-signal families)', + pkg: { dependencies: { express: '4', axios: '1' } }, + files: { + 'src/server.ts': ` + import express from "express"; + import fs from "node:fs"; + import { exec } from "node:child_process"; + import axios from "axios"; + const app = express(); + app.post("/proxy", async (req, res) => { await axios.get(req.body.target); res.end(); }); + app.post("/download", (req, res) => { res.end(fs.readFileSync(req.body.file)); }); + app.post("/convert", (req, res) => { exec(req.body.cmd); res.end(); }); + app.get("/search", (req, res) => { res.end(fs.readFileSync(req.query.doc)); }); + `, + }, + expectCandidates: [ + 'ssrf @ post.target', + 'path-traversal @ post.file', + 'command-injection @ post.cmd', + 'path-traversal @ get.doc', + ], + }, + { + id: 'stack/next-app-router', + name: 'next app router (file-based dynamic route) + server action', + pkg: { dependencies: { next: '15' } }, + files: { + 'app/api/render/route.ts': ` + import fs from "node:fs"; + export async function POST(request) { + const { template } = await request.json(); + return new Response(fs.readFileSync(template)); + } + `, + 'app/actions.ts': ` + 'use server'; + import { exec } from "node:child_process"; + export async function report(input) { exec(input.job); } + `, + }, + expectCandidates: ['path-traversal @ post.template', 'command-injection @ post.job'], + }, + { + id: 'stack/fastify-pg', + name: 'fastify + pg (raw sql vs bound values)', + pkg: { dependencies: { fastify: '4', pg: '8' } }, + files: { + 'src/app.ts': ` + import Fastify from "fastify"; + import { Pool } from "pg"; + const pool = new Pool(); + const app = Fastify(); + app.post("/raw", async (req, reply) => { await pool.query(req.body.sql); reply.send(); }); + app.post("/safe", async (req, reply) => { await pool.query("select 1 where id=$1", [req.body.id]); reply.send(); }); + `, + }, + expectCandidates: ['sql-injection @ post.sql'], + expectRefused: [['id', /not a blockable pattern/]], + }, + { + id: 'stack/supabase-edge-fn', + name: 'supabase edge function (deno) with an outbound callback', + pkg: {}, + files: { + 'supabase/functions/notify/index.ts': ` + Deno.serve(async (req) => { + const { hook } = await req.json(); + await fetch(hook, { method: "POST" }); + return new Response("ok"); + }); + `, + }, + expectCandidates: ['ssrf @ post.hook'], + }, + { + id: 'stack/unaddressable-unmodelled', + name: 'unaddressable + unmodelled shapes (must yield nothing)', + pkg: { dependencies: { express: '4', '@supabase/supabase-js': '2' } }, + files: { + 'src/edge.ts': ` + import express from "express"; + import fs from "node:fs"; + import { createClient } from "@supabase/supabase-js"; + const db = createClient("u", "k"); + const app = express(); + // A route param has no runtime coordinate at all. + app.get("/t/:tenant/f", (req, res) => { res.end(fs.readFileSync(req.params.tenant)); }); + // A dynamic computed key cannot be pinned. + app.post("/dyn", async (req, res) => { const k = req.body.which; await db.from("t").insert({ v: req.body[k] }); res.end(); }); + // A spread hides which field reaches the sink. + app.post("/spread", async (req, res) => { await db.from("t").insert({ ...req.body }); res.end(); }); + `, + }, + expectCandidates: [], + }, + { + id: 'stack/express-client-in-lib', + name: 'express + a client in lib/ (the layout generated apps actually use)', + pkg: { dependencies: { express: '4', '@supabase/supabase-js': '2', pg: '8' } }, + files: { + // The handler's file imports the CLIENT, not the driver. The receiver therefore resolves to a + // relative specifier, and treating that as app code made these sinks vanish — which also broke the + // package join a server needs to connect a CVE in `pg` to the endpoint that reaches it. + 'src/lib/db.ts': ` + import { createClient } from "@supabase/supabase-js"; + export const db = createClient(process.env.URL, process.env.KEY); + `, + 'src/lib/pool.ts': ` + import { Pool } from "pg"; + export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + `, + 'src/server.ts': ` + import express from "express"; + import { db } from "./lib/db"; + import { pool } from "./lib/pool"; + const app = express(); + app.post("/tasks", async (req, res) => { await db.from("tasks").insert({ title: req.body.title }); res.end(); }); + app.post("/report", async (req, res) => { await pool.query(req.body.sql); res.end(); }); + `, + }, + // The raw query is a blockable pattern; the inserted row value is context, not a rule. + expectCandidates: ['sql-injection @ post.sql'], + expectRefused: [['title', /not a blockable pattern/]], + }, +]; diff --git a/tests/map/corpus.test.ts b/tests/map/corpus.test.ts index 5814cf0..afd0fab 100644 --- a/tests/map/corpus.test.ts +++ b/tests/map/corpus.test.ts @@ -19,330 +19,9 @@ import type { SiteInputMap } from '../../src/map/types.js'; // regression is loud, but it is a lesser sin than a wrong pin. // Every production false positive we ever find should become a permanent case here. -interface Case { - name: string; - /** - * `stack`: a project shape a builder really generates — measures recall and correct pinning. - * `adversarial`: app code CONSTRUCTED to look dangerous. A permanent category, not a bag of - * regressions: every false-candidate class we have found came from code that merely resembled a - * dangerous API, so the corpus has to contain lookalikes on purpose. These cases must produce a - * visible surface (inputs, usually sinks) and still compile no rule. - */ - kind?: 'stack' | 'adversarial'; - pkg: Record; - files: Record; - /** `family @ runtimeParameter` for every flow that SHOULD compile to a candidate. */ - expectCandidates: string[]; - /** Proven flows that must NOT be candidates, as `input -> reason-fragment`. */ - expectRefused?: Array<[string, RegExp]>; -} - -const ADVERSARIAL: Case[] = [ - { - name: 'adversarial: app code whose exports collide with dangerous API names', - kind: 'adversarial', - pkg: { dependencies: { express: '4' } }, - files: { - 'src/util.ts': ` - export function exec(x) { return x.length } - export function query(x) { return x } - export function readFileSync(p) { return p } - export function fetch(u) { return { u } } - `, - 'src/server.ts': ` - import * as helper from "./util"; - import { fetch, exec } from "./util"; - import express from "express"; - const app = express(); - // Namespace member calls AND named imports, both from a relative module. - app.post("/ns", (req, res) => { - helper.exec(req.body.cmd); helper.query(req.body.sql); helper.readFileSync(req.body.path); - res.end(); - }); - app.post("/named", (req, res) => { fetch(req.body.url); exec(req.body.cmd2); res.end(); }); - `, - }, - expectCandidates: [], - }, - { - name: 'adversarial: untraceable receivers in a file that imports real db clients', - kind: 'adversarial', - pkg: { dependencies: { express: '4', pg: '8', '@supabase/supabase-js': '2' } }, - files: { - 'src/server.ts': ` - import { Pool } from "pg"; - import { createClient } from "@supabase/supabase-js"; - import express from "express"; - const app = express(); - // The package is only INFERRED from the file's imports; the receivers are app objects. - app.post("/raw", (req, res) => { res.locals.db.query(req.body.sql); res.end(); }); - app.post("/from", (req, res) => { res.locals.sb.from("t").insert({ v: req.body.v }); res.end(); }); - `, - }, - expectCandidates: [], - expectRefused: [['sql', /inferred from the file's other imports/]], - }, - { - name: 'adversarial: parameters shadowing dangerous globals', - kind: 'adversarial', - pkg: { dependencies: { express: '4' } }, - files: { - 'src/server.ts': ` - import express from "express"; - const app = express(); - app.post("/shadow", (req, res) => { - const send = (fetch) => fetch(req.body.url); - send((u) => ({ u })); - const run = (eval2) => eval2(req.body.code); - run((c) => c); - res.end(); - }); - `, - }, - expectCandidates: [], - }, - { - name: 'adversarial: one field name read from two request namespaces, addressed separately', - kind: 'adversarial', - pkg: { dependencies: { express: '4' } }, - files: { - // `params.id` and `query.id` share a NAME but not an address, so they are two inputs. Name-keyed - // extraction kept one of them and let its coordinate stand for both, which pinned a rule to - // `get.id` for data arriving in the path segment. Now each is addressed on its own: the query read - // earns `get.id`, the route param earns nothing (the resolver cannot reach it). Both source orders - // are covered because the order dependence is what made it a bug. - 'src/a.ts': ` - import express from "express"; - import fs from "node:fs"; - const app = express(); - app.get("/qp/:id", ({ params: p, query: q }, res) => { fs.readFileSync(q.id); fs.readFileSync(p.id); res.end(); }); - `, - 'src/b.ts': ` - import express from "express"; - import fs from "node:fs"; - const app = express(); - app.get("/pq/:id", ({ params: p, query: q }, res) => { fs.readFileSync(p.id); fs.readFileSync(q.id); res.end(); }); - `, - }, - expectCandidates: ['path-traversal @ get.id', 'path-traversal @ get.id'], - expectRefused: [['id', /route parameters are not exposed/]], - }, - { - name: 'adversarial: a validator field and the sink read address different namespaces', - kind: 'adversarial', - pkg: { dependencies: { express: '4', zod: '3' } }, - files: { - // The schema describes the BODY; the sink consumes the QUERY. Both are called `id`, and grouping - // inputs by name made the schema's `post.id` the only surviving entry — so the candidate pinned a - // parameter the payload never travels in. As separate identities the query read is pinned correctly - // and the declared-but-unread body field simply has no proven flow. - 'src/server.ts': ` - import express from "express"; - import fs from "node:fs"; - import { z } from "zod"; - const app = express(); - app.post("/mismatch", (req, res) => { - z.object({ id: z.string() }).parse(req.body); - res.end(fs.readFileSync(req.query.id)); - }); - app.post("/agree", (req, res) => { - z.object({ doc: z.string() }).parse(req.body); - res.end(fs.readFileSync(req.body.doc)); - }); - `, - }, - // `/agree` must still compile: a validated body field read by the sink is the common good case. - expectCandidates: ['path-traversal @ get.id', 'path-traversal @ post.doc'], - // The schema's `post:id` is declared but never read by the sink — proven-nothing, not blockable. - expectRefused: [['id', /no proven local read/]], - }, - { - name: 'adversarial: a traced package that does not establish the API', - kind: 'adversarial', - pkg: { dependencies: { express: '4', '@apollo/client': '3' } }, - files: { - // `.query()` is a generic method name. An ApolloClient instance resolves to a REAL dependency, so - // attribution alone admits it — and a GraphQL call became a precise SQL-injection candidate. Package - // provenance is not API provenance. - 'src/lib/gql.ts': ` - import { ApolloClient } from "@apollo/client"; - export const client = new ApolloClient({ uri: "https://api.example.com" }); - `, - 'src/server.ts': ` - import express from "express"; - import { client } from "./lib/gql"; - const app = express(); - app.post("/graphql", async (req, res) => { await client.query(req.body.sql); res.end(); }); - `, - }, - expectCandidates: [], - expectRefused: [['sql', /does not establish a db API/]], - }, - { - name: 'adversarial: sibling expressions must not contaminate each other', - kind: 'adversarial', - pkg: { dependencies: { express: '4' } }, - files: { - 'src/server.ts': ` - import express from "express"; - import fs from "node:fs"; - import { exec } from "node:child_process"; - const STATIC = "ls -la"; - const app = express(); - // Only ONE pairing is real: path -> readFileSync. \`label\` reaches no sink, and the exec call - // takes no request data at all — an inventory-level "both present" must not become a flow. - app.post("/two", (req, res) => { - const label = req.body.label; - fs.readFileSync(req.body.path); - exec(STATIC); - res.end(label); - }); - `, - }, - expectCandidates: ['path-traversal @ post.path'], - }, -]; - -const CASES: Case[] = [ - { - name: 'lovable / tanstack start + supabase (server fns, validated payload)', - pkg: { dependencies: { '@tanstack/react-start': '1', zod: '3', '@supabase/supabase-js': '2' } }, - files: { - 'src/lib/tasks.functions.ts': ` - import { createServerFn } from "@tanstack/react-start"; - import { z } from "zod"; - import { createClient } from "@supabase/supabase-js"; - function getClient() { return createClient(process.env.URL, process.env.KEY); } - export const createTask = createServerFn({ method: "POST" }) - .inputValidator((i) => z.object({ title: z.string().min(1).max(200) }).parse(i)) - .handler(async ({ data }) => { await getClient().from("tasks").insert({ title: data.title }); }); - `, - }, - // A request value in a parameterized insert is reachability signal, not a blockable pattern. - expectCandidates: [], - expectRefused: [['title', /not a blockable pattern/]], - }, - { - name: 'express + axios + fs + child_process (the high-signal families)', - pkg: { dependencies: { express: '4', axios: '1' } }, - files: { - 'src/server.ts': ` - import express from "express"; - import fs from "node:fs"; - import { exec } from "node:child_process"; - import axios from "axios"; - const app = express(); - app.post("/proxy", async (req, res) => { await axios.get(req.body.target); res.end(); }); - app.post("/download", (req, res) => { res.end(fs.readFileSync(req.body.file)); }); - app.post("/convert", (req, res) => { exec(req.body.cmd); res.end(); }); - app.get("/search", (req, res) => { res.end(fs.readFileSync(req.query.doc)); }); - `, - }, - expectCandidates: [ - 'ssrf @ post.target', - 'path-traversal @ post.file', - 'command-injection @ post.cmd', - 'path-traversal @ get.doc', - ], - }, - { - name: 'next app router (file-based dynamic route) + server action', - pkg: { dependencies: { next: '15' } }, - files: { - 'app/api/render/route.ts': ` - import fs from "node:fs"; - export async function POST(request) { - const { template } = await request.json(); - return new Response(fs.readFileSync(template)); - } - `, - 'app/actions.ts': ` - 'use server'; - import { exec } from "node:child_process"; - export async function report(input) { exec(input.job); } - `, - }, - expectCandidates: ['path-traversal @ post.template', 'command-injection @ post.job'], - }, - { - name: 'fastify + pg (raw sql vs bound values)', - pkg: { dependencies: { fastify: '4', pg: '8' } }, - files: { - 'src/app.ts': ` - import Fastify from "fastify"; - import { Pool } from "pg"; - const pool = new Pool(); - const app = Fastify(); - app.post("/raw", async (req, reply) => { await pool.query(req.body.sql); reply.send(); }); - app.post("/safe", async (req, reply) => { await pool.query("select 1 where id=$1", [req.body.id]); reply.send(); }); - `, - }, - expectCandidates: ['sql-injection @ post.sql'], - expectRefused: [['id', /not a blockable pattern/]], - }, - { - name: 'supabase edge function (deno) with an outbound callback', - pkg: {}, - files: { - 'supabase/functions/notify/index.ts': ` - Deno.serve(async (req) => { - const { hook } = await req.json(); - await fetch(hook, { method: "POST" }); - return new Response("ok"); - }); - `, - }, - expectCandidates: ['ssrf @ post.hook'], - }, - { - name: 'unaddressable + unmodelled shapes (must yield nothing)', - pkg: { dependencies: { express: '4', '@supabase/supabase-js': '2' } }, - files: { - 'src/edge.ts': ` - import express from "express"; - import fs from "node:fs"; - import { createClient } from "@supabase/supabase-js"; - const db = createClient("u", "k"); - const app = express(); - // A route param has no runtime coordinate at all. - app.get("/t/:tenant/f", (req, res) => { res.end(fs.readFileSync(req.params.tenant)); }); - // A dynamic computed key cannot be pinned. - app.post("/dyn", async (req, res) => { const k = req.body.which; await db.from("t").insert({ v: req.body[k] }); res.end(); }); - // A spread hides which field reaches the sink. - app.post("/spread", async (req, res) => { await db.from("t").insert({ ...req.body }); res.end(); }); - `, - }, - expectCandidates: [], - }, - { - name: 'express + a client in lib/ (the layout generated apps actually use)', - pkg: { dependencies: { express: '4', '@supabase/supabase-js': '2', pg: '8' } }, - files: { - // The handler's file imports the CLIENT, not the driver. The receiver therefore resolves to a - // relative specifier, and treating that as app code made these sinks vanish — which also broke the - // package join a server needs to connect a CVE in `pg` to the endpoint that reaches it. - 'src/lib/db.ts': ` - import { createClient } from "@supabase/supabase-js"; - export const db = createClient(process.env.URL, process.env.KEY); - `, - 'src/lib/pool.ts': ` - import { Pool } from "pg"; - export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - `, - 'src/server.ts': ` - import express from "express"; - import { db } from "./lib/db"; - import { pool } from "./lib/pool"; - const app = express(); - app.post("/tasks", async (req, res) => { await db.from("tasks").insert({ title: req.body.title }); res.end(); }); - app.post("/report", async (req, res) => { await pool.query(req.body.sql); res.end(); }); - `, - }, - // The raw query is a blockable pattern; the inserted row value is context, not a rule. - expectCandidates: ['sql-injection @ post.sql'], - expectRefused: [['title', /not a blockable pattern/]], - }, -]; +// The cases live in `corpus-cases.ts` so other suites can reference a specific one by id — see +// `CAPABILITY_CONTROLS`, which names the adversarial case that covers each rule-generatable capability. +import { ADVERSARIAL, CASES, type Case } from './corpus-cases.js'; const ALL: Case[] = [...CASES, ...ADVERSARIAL]; @@ -361,7 +40,7 @@ beforeAll(async () => { writeFileSync(join(d, 'package.json'), JSON.stringify(c.pkg)); const { map, error } = await buildInputMap(d); expect(error, `${c.name}: ${error}`).toBeUndefined(); - maps.set(c.name, map!); + maps.set(c.id, map!); } }, 120_000); afterAll(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true }))); @@ -387,7 +66,7 @@ describe('golden corpus', () => { for (const c of ALL) { describe(c.name, () => { it('compiles exactly the expected candidates — no wrong-input pins', () => { - const got = candidatesOf(maps.get(c.name)!); + const got = candidatesOf(maps.get(c.id)!); const want = [...c.expectCandidates].sort(); // A candidate nobody declared is a WRONG-INPUT pin: the metric that must stay at zero. expect(got.filter((g) => !want.includes(g)), 'unexpected candidate(s)').toEqual([]); @@ -395,7 +74,7 @@ describe('golden corpus', () => { }); it('refuses the flows that are proven but not blockable, with a reason', () => { - const map = maps.get(c.name)!; + const map = maps.get(c.id)!; for (const [input, reason] of c.expectRefused ?? []) { const flows = map.endpoints.flatMap((e) => e.flows).filter((f) => f.input === input); expect(flows.length, `no flow for input ${input}`).toBeGreaterThan(0); @@ -406,7 +85,7 @@ describe('golden corpus', () => { }); it('never emits a candidate whose input lacks a runtime coordinate', () => { - const map = maps.get(c.name)!; + const map = maps.get(c.id)!; for (const ep of map.endpoints) { const coord = new Map(ep.inputs.map((i) => [i.id, i.runtimeParameter])); for (const f of ep.flows.filter((x) => x.ruleGeneratable)) { @@ -416,7 +95,7 @@ describe('golden corpus', () => { }); it('gives every input a unique identity, and every flow a real one to point at', () => { - const map = maps.get(c.name)!; + const map = maps.get(c.id)!; for (const ep of map.endpoints) { const ids = ep.inputs.map((i) => i.id); expect(new Set(ids).size, `${ep.route ?? ep.name}: duplicate input ids`).toBe(ids.length); @@ -431,7 +110,7 @@ describe('golden corpus', () => { // actually saw the handler and the request fields, and only then that it compiled no rule. it('adversarial cases detect a real surface and still refuse to compile a rule', () => { for (const c of ADVERSARIAL) { - const map = maps.get(c.name)!; + const map = maps.get(c.id)!; const inputs = map.endpoints.flatMap((e) => e.inputs); expect(map.endpoints.length, `${c.name}: no endpoint detected — the fixture proves nothing`).toBeGreaterThan(0); expect(inputs.length, `${c.name}: no inputs detected — the fixture proves nothing`).toBeGreaterThan(0); @@ -456,7 +135,7 @@ describe('golden corpus', () => { let candidates = 0, refusedWithReason = 0, noCoordinate = 0; const tiers = new Map(); for (const c of ALL) { - for (const ep of maps.get(c.name)!.endpoints) { + for (const ep of maps.get(c.id)!.endpoints) { for (const i of ep.inputs) if (!i.runtimeParameter) noCoordinate++; for (const f of ep.flows) { tiers.set(f.confidence, (tiers.get(f.confidence) ?? 0) + 1); @@ -472,7 +151,7 @@ describe('golden corpus', () => { expect(refusedWithReason).toBeGreaterThan(0); // and refuses a lot, explicitly // Every non-candidate must explain itself: silence is what makes a map untrustworthy. for (const c of ALL) { - for (const ep of maps.get(c.name)!.endpoints) { + for (const ep of maps.get(c.id)!.endpoints) { for (const f of ep.flows.filter((x) => x.ruleGeneratable === false)) { expect(f.ruleGeneratableReasons?.length, `${c.name}/${f.input}: refused without a reason`).toBeGreaterThan(0); }