Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions capabilities.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
102 changes: 102 additions & 0 deletions scripts/check-capability-version.mjs
Original file line number Diff line number Diff line change
@@ -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 <ref>] (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}).`);
}
64 changes: 64 additions & 0 deletions scripts/emit-capabilities.mjs
Original file line number Diff line number Diff line change
@@ -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})`);
}
Loading
Loading