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
15 changes: 14 additions & 1 deletion capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$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.1.0",
"version": "1.2.0",
"sinkKinds": [
"db",
"fs",
Expand Down Expand Up @@ -59,6 +59,19 @@
"route-param",
"unknown"
],
"inputSources": [
"json-body",
"form-body",
"multipart",
"body",
"query",
"route-param",
"header",
"cookie",
"file",
"server-fn-data",
"unknown"
],
"invocationKinds": [
"call",
"member",
Expand Down
1 change: 1 addition & 0 deletions scripts/emit-capabilities.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const manifest = {
autoPromotableConfidence: stringOf('AUTO_PROMOTABLE_CONFIDENCE'),
attributions: arrayOf('ATTRIBUTIONS'),
addressSpaces: arrayOf('ADDRESS_SPACES'),
inputSources: arrayOf('INPUT_SOURCES'),
invocationKinds: arrayOf('INVOCATION_KINDS'),
invocationResolutions: arrayOf('INVOCATION_RESOLUTIONS'),
};
Expand Down
18 changes: 17 additions & 1 deletion src/map/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* 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.1.0';
export const CAPABILITY_VERSION = '1.2.0';

/** Sink families the extractor recognizes. A dangerous OPERATION, not a package. */
export const SINK_KINDS = ['db', 'fs', 'http', 'exec', 'eval'] as const;
Expand Down Expand Up @@ -79,6 +79,21 @@ 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;

/**
* Where an input is read from. The other half of an input's identity, and the axis that decides which
* address space — and therefore which engine parameter — can name it.
*
* A runtime array, like every other vocabulary here, because `InputSource` was a bare type union and a
* union cannot be enumerated at runtime. That is not a stylistic gap: a test asked to cover "every source
* the map can address" had no way to ask what those were, so it restated the list — and a restated list
* compared against another restated list passes forever, including after a sixth source is added.
*/
export const INPUT_SOURCES = [
'json-body', 'form-body', 'multipart', 'body',
'query', 'route-param', 'header', 'cookie', 'file',
'server-fn-data', '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,
Expand All @@ -90,6 +105,7 @@ export const CAPABILITY_MANIFEST = {
autoPromotableConfidence: AUTO_PROMOTABLE_CONFIDENCE,
attributions: ATTRIBUTIONS,
addressSpaces: ADDRESS_SPACES,
inputSources: INPUT_SOURCES,
invocationKinds: INVOCATION_KINDS,
invocationResolutions: INVOCATION_RESOLUTIONS,
} as const;
Expand Down
21 changes: 8 additions & 13 deletions src/map/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
// 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,
INVOCATION_KINDS,
INVOCATION_RESOLUTIONS,
SINK_KINDS,
} from './capabilities.js';
import { ADDRESS_SPACES, ARGUMENT_ROLES, CANDIDATE_FAMILIES, INPUT_SOURCES, INVOCATION_KINDS, INVOCATION_RESOLUTIONS, 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.
Expand All @@ -17,11 +10,13 @@ import {
// Honesty is a first-class field: static analysis is best-effort, so `coverage` records what the
// adapter could and couldn't see. Never present the map as "complete".

/** Where an input is read from — determines which runtime parameter namespace can address it. */
export type InputSource =
| 'json-body' | 'form-body' | 'multipart' | 'body'
| 'query' | 'route-param' | 'header' | 'cookie' | 'file'
| 'server-fn-data' | 'unknown';
/**
* Where an input is read from — determines which runtime parameter namespace can address it.
*
* Derived from the runtime vocabulary rather than written out again, so the two cannot disagree and so a
* test can ask what the members ARE instead of restating them.
*/
export type InputSource = (typeof INPUT_SOURCES)[number];

/**
* Where an input lives in the request, as the rule engine addresses it. This — not the field name — is
Expand Down
37 changes: 37 additions & 0 deletions tests/map/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,40 @@ describe('every declared capability has a recognizer behind it', () => {
}, 60_000);

});
/**
* Every vocabulary declared here must actually reach the manifest consumers vendor.
*
* This was found the hard way: `INPUT_SOURCES` was added to `CAPABILITY_MANIFEST` and did not appear in
* `capabilities.json`, because the emitter assembles the JSON from its OWN restated list of vocabulary
* names. `emit-capabilities --check` reported "up to date" the whole time — both sides omitted it, so they
* agreed. A member could be added, typed, used, and never ship, and the check designed to catch exactly
* that said nothing.
*
* Asserting over the source rather than over either list is the only version of this that cannot be
* satisfied by two files agreeing to be wrong together.
*/
describe('the emitted manifest covers every declared vocabulary', () => {
const source = readFileSync(join(root, 'src', 'map', 'capabilities.ts'), 'utf8');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));

// Exported `as const` arrays are the vocabularies. `CAPABILITY_MANIFEST` itself is the assembly, not a
// member, and the scalar pins are read separately by the emitter.
const declared = [...source.matchAll(/export const ([A-Z_]+) = \[/g)]
.map((m) => m[1])
.filter((name) => name !== 'CAPABILITY_MANIFEST');

it('finds the vocabularies to check, so an empty list cannot pass', () => {
expect(declared.length).toBeGreaterThanOrEqual(8);
});

it.each(declared)('%s appears in capabilities.json with the same members', (name) => {
// camelCase key, as the manifest names them: SINK_KINDS → sinkKinds.
const key = name.toLowerCase().replace(/_(.)/g, (_, c) => c.toUpperCase());
const members = [...(new RegExp(`export const ${name} = \\[([^\\]]*)\\] as const;`, 's').exec(source)?.[1] ?? '')
.matchAll(/'([^']+)'/g)].map((m) => m[1]);

expect(manifest, `${name} is declared but ${key} is absent from capabilities.json`).toHaveProperty(key);
expect(manifest[key]).toEqual(members);
});
});

50 changes: 39 additions & 11 deletions tests/protect/coordinate-round-trip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { buildInputMap } from '../../src/map/index.js';
import { createProtection } from '../../src/protect/runtime.js';
import { INPUT_SOURCES } from '../../src/map/capabilities.js';
import { runtimeCoordinate } from '../../src/map/coordinates.js';

/**
* The coordinate the MAP emits is the coordinate the ENGINE resolves.
Expand Down Expand Up @@ -100,7 +102,8 @@ const multipart = (filename: string) => {
};

interface Case {
space: string;
/** The `InputSource` this case drives, so coverage can be compared against the vocabulary. */
source: (typeof INPUT_SOURCES)[number];
input: string;
expected: string;
exploit: () => Request;
Expand All @@ -109,36 +112,36 @@ interface Case {

const cases: Case[] = [
{
space: 'body', input: 'note', expected: 'post.note',
source: 'body', input: 'note', expected: 'post.note',
exploit: () => new Request(URL_BASE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ note: EXPLOIT }) }),
benign: () => new Request(URL_BASE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ note: 'hello' }) }),
},
{
space: 'query', input: 'q', expected: 'get.q',
source: 'query', input: 'q', expected: 'get.q',
exploit: () => new Request(`${URL_BASE}?q=${EXPLOIT}`),
benign: () => new Request(`${URL_BASE}?q=hello`),
},
{
space: 'cookie', input: 'session', expected: 'cookie.session',
source: 'cookie', input: 'session', expected: 'cookie.session',
exploit: () => new Request(URL_BASE, { headers: { cookie: `session=${EXPLOIT}` } }),
benign: () => new Request(URL_BASE, { headers: { cookie: 'session=abc123' } }),
},
{
space: 'header', input: 'x-api-key', expected: 'server.HTTP_X_API_KEY',
source: 'header', input: 'x-api-key', expected: 'server.HTTP_X_API_KEY',
exploit: () => new Request(URL_BASE, { headers: { 'x-api-key': EXPLOIT } }),
benign: () => new Request(URL_BASE, { headers: { 'x-api-key': 'abc123' } }),
},
{
// A bare `files.<field>` resolves to the FILENAME, so that is where the payload goes.
space: 'file', input: 'avatar', expected: 'files.avatar',
source: 'file', input: 'avatar', expected: 'files.avatar',
exploit: () => multipart(`${EXPLOIT}.png`),
benign: () => multipart('holiday.png'),
},
];

describe('a coordinate the map emitted', () => {
for (const c of cases) {
describe(`${c.space} (${c.input})`, () => {
describe(`${c.source} (${c.input})`, () => {
it('is the coordinate this suite expects the engine to need', () => {
// Guards the test itself: if extraction stopped emitting this input, every assertion below would
// be skipped on an undefined coordinate and the space would silently lose its coverage.
Expand All @@ -155,9 +158,34 @@ describe('a coordinate the map emitted', () => {
});
}

it('covers every space the map can address', () => {
// Without this, adding a new addressable space to `coordinates.ts` leaves it unverified against the
// resolver, which is the state this suite exists to end.
expect(new Set(cases.map((c) => c.space))).toEqual(new Set(['body', 'query', 'cookie', 'header', 'file']));
it('covers every source the map can address, derived from the vocabulary', () => {
// The previous version compared one hard-coded set against another hard-coded set, so it asserted that
// this file agreed with itself. A sixth addressable source would not have failed it — which is exactly
// the gap it was written to close.
//
// The expectation is now computed from production: a source is addressable when `runtimeCoordinate`
// yields a parameter for it, and every addressable source must have a case here. Adding one to
// `INPUT_SOURCES` and giving it a coordinate now fails this until it is driven through the engine.
const addressable = INPUT_SOURCES.filter(
(source) => runtimeCoordinate(source, 'field').runtimeParameter !== null,
);
const covered = new Set(cases.map((c) => c.source));

// The body-shaped sources all resolve to `post.<path>`, so one case covers them; asserted explicitly
// rather than assumed, because a divergence would otherwise hide behind the grouping.
const bodyShaped = ['json-body', 'form-body', 'multipart', 'body', 'server-fn-data'] as const;
for (const source of bodyShaped) {
expect(runtimeCoordinate(source, 'field').runtimeParameter).toBe('post.field');
}

const needsItsOwnCase = addressable.filter((s) => !bodyShaped.includes(s as never));
expect(new Set([...needsItsOwnCase, 'body'])).toEqual(covered);

// And the unaddressable ones stay unaddressable: a coordinate appearing for a route parameter would
// compile a rule the resolver cannot resolve, which is the failure this whole file is about.
for (const source of INPUT_SOURCES.filter((s) => !addressable.includes(s))) {
expect(runtimeCoordinate(source, 'field').runtimeParameter).toBeNull();
expect(runtimeCoordinate(source, 'field').runtimeParameterReason).toBeTruthy();
}
});
});
Loading