From 2cdc9a826f3a3abefe4e76223e89dda24ee973cf Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 17 Aug 2026 11:00:26 +0200 Subject: [PATCH 1/3] map: one versioned capability contract instead of five copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closed vocabularies that describe what the map can see — sink kinds, argument roles, candidate families, confidence tiers — were declared independently in five places: the TypeScript unions here, and separate copies in the rule-authoring and rule-binding layers that consume the map. Adding a capability meant editing all of them, and the failure mode of missing one is silent: a sink kind this side emits and an authoring layer rejects makes the capability unauthorable, one the binding layer does not know makes every flow of that kind unusable. Nothing errors. The capability simply never matches, which reads exactly like "not reachable". That is the same shape as the wrong-pin bugs in the map hardening track: three instances, one lossy representation. The fix is the representation. - `src/map/capabilities.ts` is now the single definition, versioned, with the reasoning for why a new member is a contract change and not a list edit. - The TypeScript unions are DERIVED from it (`(typeof SINK_KINDS)[number]`) rather than declared beside it, which would have been a second copy with the same drift risk. - `capabilities.json` is generated and committed, so a vocabulary change is a reviewable diff in the contract. It is not added to package.json `files`: the consumers vendor from source, and the published surface is separately reviewed. - Tests defend the contract, not just the values: the committed manifest must be byte-identical to what the emitter produces (verified by adding a sink kind and watching it fail), the manifest must agree with the TS member for member, the auto-promotable tier must remain the single strongest proven tier, and everything the extractor actually emits must fall inside the vocabulary — with a non-vacuity check first, since a fixture producing no sinks would satisfy that trivially. --- capabilities.json | 62 ++++++++++++++++ package.json | 3 +- scripts/emit-capabilities.mjs | 64 +++++++++++++++++ src/map/capabilities.ts | 77 ++++++++++++++++++++ src/map/types.ts | 23 +++--- tests/map/capabilities.test.ts | 127 +++++++++++++++++++++++++++++++++ 6 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 capabilities.json create mode 100644 scripts/emit-capabilities.mjs create mode 100644 src/map/capabilities.ts create mode 100644 tests/map/capabilities.test.ts 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..71ce88c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "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" }, "engines": { "node": ">=18" 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..bfcf5a4 --- /dev/null +++ b/src/map/capabilities.ts @@ -0,0 +1,77 @@ +// 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; 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..8e1d53f --- /dev/null +++ b/tests/map/capabilities.test.ts @@ -0,0 +1,127 @@ +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, + 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 { 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 }); + }); +}); From 325b297182781baad72e7d8a7fb73c5c61ef0b26 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 17 Aug 2026 11:17:39 +0200 Subject: [PATCH 2/3] map: a declared capability must be recognized, and a vocabulary change must bump the version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the contract could still drift silently. A capability could be declared with nothing behind it. Adding a sink kind, regenerating the manifest and teaching every consumer to accept it succeeded even if no recognizer ever emitted that kind — a vocabulary entry with no behaviour, unlocking a rule family that can never fire. The existing "everything emitted is inside the vocabulary" test cannot see it: that only checks the kinds that DO emit are legal, and a fixture exercising the other four still passes a "we emit some sinks" assertion. Each kind now names its own control — the minimal shape that must produce a sink of exactly that kind — and the tests run every control, assert the kind appears, and for a rule-generatable kind assert a candidate with a real family compiles. Because the matrix is typed Record, adding a kind without a control is a TYPE error: the compiler asks before CI does. A kind that can compile a rule can also compile a WRONG one, so those additionally require an adversarial lookalike in the corpus, asserted rather than assumed. And the version could stay put. A member added or removed without moving CAPABILITY_VERSION left every vendoring consumer unable to tell it was behind — the same silent drift the manifest exists to remove, one level up. check-capability-version.mjs compares the manifest against the base branch and classifies: a member or field added needs a minor bump, a member removed or a scalar redefined is BREAKING and needs a major one, because a consumer pinned to the old list keeps emitting a value that can no longer match. Wired as its own CI job with full history. Both verified by simulation: declaring an unrecognized capability fails three tests (no sink, no candidate, no adversarial coverage), and the four version paths — additive without a bump, additive with one, a removal under a minor bump, and a redefined scalar — each behave as specified. --- .github/workflows/ci.yml | 27 +++++++ package.json | 3 +- scripts/check-capability-version.mjs | 102 +++++++++++++++++++++++++++ src/map/capabilities.ts | 65 +++++++++++++++++ tests/map/capabilities.test.ts | 76 ++++++++++++++++++++ 5 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 scripts/check-capability-version.mjs 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/package.json b/package.json index 71ce88c..839f4a5 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "typecheck:templates": "node scripts/typecheck-templates.mjs", "prepare": "npm run build", "prepublishOnly": "npm run typecheck && npm test && npm run build", - "capabilities": "node scripts/emit-capabilities.mjs" + "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/src/map/capabilities.ts b/src/map/capabilities.ts index bfcf5a4..70adf60 100644 --- a/src/map/capabilities.ts +++ b/src/map/capabilities.ts @@ -75,3 +75,68 @@ export const CAPABILITY_MANIFEST = { 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. + */ +export interface CapabilityControl { + /** + * 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[]; + /** + * true when a proven flow into this kind can compile a rule. Those are the kinds where a wrong + * recognizer blocks real traffic, so they additionally owe adversarial corpus coverage — a lookalike + * that must produce NO candidate. See the adversarial category in tests/map/corpus.test.ts. + */ + ruleGeneratable: boolean; +} + +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, + }, + fs: { + setup: 'import fs from "node:fs";', + control: 'fs.readFileSync(req.body.path);', + deps: [], + ruleGeneratable: true, + }, + http: { + setup: '', + control: 'fetch(req.body.url);', + deps: [], + ruleGeneratable: true, + }, + exec: { + setup: 'import { exec } from "node:child_process";', + control: 'exec(req.body.cmd);', + deps: [], + ruleGeneratable: true, + }, + eval: { + setup: '', + control: 'eval(req.body.code);', + deps: [], + ruleGeneratable: true, + }, +}; diff --git a/tests/map/capabilities.test.ts b/tests/map/capabilities.test.ts index 8e1d53f..06485c3 100644 --- a/tests/map/capabilities.test.ts +++ b/tests/map/capabilities.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { ADDRESS_SPACES, + CAPABILITY_CONTROLS, ARGUMENT_ROLES, CANDIDATE_FAMILIES, CAPABILITY_MANIFEST, @@ -125,3 +126,78 @@ describe('what the map emits stays inside the contract', () => { 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); + // A candidate with no family cannot be turned into a rule, so the claim would be hollow. + for (const f of generatable) { + expect(f.candidateFamily, `'${kind}' candidate has no candidateFamily`).toBeDefined(); + expect(CANDIDATE_FAMILIES as readonly string[]).toContain(f.candidateFamily!); + } + }); + } + } + + it('requires adversarial coverage for every rule-generatable capability', () => { + // A capability that can compile a rule can also compile a WRONG rule, and every wrong-pin bug we + // have found came from code that merely resembled a dangerous API. So the lookalike case is a + // precondition for rule generation, not an optional extra: this asserts the adversarial corpus + // actually exercises each such kind rather than trusting that it does. + const corpus = readFileSync(join(root, 'tests/map/corpus.test.ts'), 'utf8'); + const adversarial = corpus.slice(corpus.indexOf('const ADVERSARIAL'), corpus.indexOf('const STACKS')); + expect(adversarial.length, 'could not locate the adversarial corpus block').toBeGreaterThan(200); + const missing = (Object.keys(CAPABILITY_CONTROLS) as Array) + .filter((kind) => CAPABILITY_CONTROLS[kind].ruleGeneratable) + // The lookalike is written in the app's own vocabulary, so match on the API the control calls + // (`query`, `readFileSync`, `exec`, `fetch`, `eval`) rather than on the kind's name. + .filter((kind) => { + const api = /([A-Za-z_$][\w$]*)\s*\(/.exec(CAPABILITY_CONTROLS[kind].control); + const needle = api ? api[1]!.split('.').pop()! : kind; + return !adversarial.includes(needle); + }); + expect(missing, `rule-generatable capabilities with no adversarial lookalike: ${missing.join(', ')}`).toEqual([]); + }); +}); From 0727b66e3d63ba979c8a4199712b7f7bbd873668 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 17 Aug 2026 11:24:13 +0200 Subject: [PATCH 3/3] map: capability coverage is a declaration link, and the expected class is exact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the coverage checks could pass without proving anything. Adversarial coverage was a text search over the corpus file: any occurrence of an API name counted, so a comment, a positive fixture, or an unrelated case satisfied it. That is coverage that can be true by coincidence — the same substring reasoning this suite exists to reject. The cases now live in tests/map/corpus-cases.ts with stable ids, and each rule-generatable capability names the adversarial case that covers it. The test resolves that id to a real case, asserts it is in the adversarial category, and asserts it declares no expected candidates — then BUILDS it and checks the extractor agrees, including the corpus non-vacuity rule, since a case that detects nothing at all would satisfy a zero-candidate assertion for the wrong reason. That exposed a genuine gap while wiring it up: eval had no adversarial case. It is recognized only as a bare global call or `new Function`, gated on the name not being locally bound, and nothing asserted the gate. adv/local-eval-lookalikes covers it — a locally-declared `Function` and a member `.eval()` on an untraceable receiver, neither of which may compile code injection. And the controls only required "some rule-generatable flow with some family". A recognizer that classified a flow as the wrong mitigation class would have passed, which is a rule that inspects the wrong thing and blocks the wrong traffic. Each rule-generatable control now declares its exact argumentRole and candidateFamily and the test asserts equality. The type is a discriminated union, so a capability declared rule-generatable without a role, a family and a case id does not compile. Verified by simulation: a wrong family fails, an id that resolves to nothing fails, and an id pointing at a case that legitimately expects candidates fails. --- src/map/capabilities.ts | 50 ++++- tests/map/capabilities.test.ts | 68 ++++-- tests/map/corpus-cases.ts | 373 +++++++++++++++++++++++++++++++++ tests/map/corpus.test.ts | 343 +----------------------------- 4 files changed, 474 insertions(+), 360 deletions(-) create mode 100644 tests/map/corpus-cases.ts diff --git a/src/map/capabilities.ts b/src/map/capabilities.ts index 70adf60..275bfdb 100644 --- a/src/map/capabilities.ts +++ b/src/map/capabilities.ts @@ -90,7 +90,8 @@ export const CAPABILITY_MANIFEST = { * `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. */ -export interface CapabilityControl { +/** 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. @@ -100,43 +101,78 @@ export interface CapabilityControl { setup: string; /** Dependencies the fixture's package.json must declare. */ deps: string[]; - /** - * true when a proven flow into this kind can compile a rule. Those are the kinds where a wrong - * recognizer blocks real traffic, so they additionally owe adversarial corpus coverage — a lookalike - * that must produce NO candidate. See the adversarial category in tests/map/corpus.test.ts. - */ - ruleGeneratable: boolean; } +/** + * 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/tests/map/capabilities.test.ts b/tests/map/capabilities.test.ts index 06485c3..99e40e3 100644 --- a/tests/map/capabilities.test.ts +++ b/tests/map/capabilities.test.ts @@ -14,6 +14,7 @@ import { 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'; @@ -172,32 +173,57 @@ describe('every declared capability has a recognizer behind it', () => { 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); - // A candidate with no family cannot be turned into a rule, so the claim would be hollow. + // 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.candidateFamily, `'${kind}' candidate has no candidateFamily`).toBeDefined(); - expect(CANDIDATE_FAMILIES as readonly string[]).toContain(f.candidateFamily!); + expect(f.argumentRole, `'${kind}' candidate role`).toBe(ctl.expectRole); + expect(f.candidateFamily, `'${kind}' candidate family`).toBe(ctl.expectFamily); } }); } } - it('requires adversarial coverage for every rule-generatable capability', () => { - // A capability that can compile a rule can also compile a WRONG rule, and every wrong-pin bug we - // have found came from code that merely resembled a dangerous API. So the lookalike case is a - // precondition for rule generation, not an optional extra: this asserts the adversarial corpus - // actually exercises each such kind rather than trusting that it does. - const corpus = readFileSync(join(root, 'tests/map/corpus.test.ts'), 'utf8'); - const adversarial = corpus.slice(corpus.indexOf('const ADVERSARIAL'), corpus.indexOf('const STACKS')); - expect(adversarial.length, 'could not locate the adversarial corpus block').toBeGreaterThan(200); - const missing = (Object.keys(CAPABILITY_CONTROLS) as Array) - .filter((kind) => CAPABILITY_CONTROLS[kind].ruleGeneratable) - // The lookalike is written in the app's own vocabulary, so match on the API the control calls - // (`query`, `readFileSync`, `exec`, `fetch`, `eval`) rather than on the kind's name. - .filter((kind) => { - const api = /([A-Za-z_$][\w$]*)\s*\(/.exec(CAPABILITY_CONTROLS[kind].control); - const needle = api ? api[1]!.split('.').pop()! : kind; - return !adversarial.includes(needle); - }); - expect(missing, `rule-generatable capabilities with no adversarial lookalike: ${missing.join(', ')}`).toEqual([]); + 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); }