Skip to content

Commit ed99ca5

Browse files
Emit the canary map, and assert what the chain depends on (#185)
The cross-repo canary is one vertical slice through the whole product claim — a reviewed recipe in hub, a verdict and generated rule in the platform, and this engine blocking the exploit — proved as a chain rather than as three sets of green unit tests. CVE-2017-5941 is the slice: `node-serialize`'s `unserialize` executes an embedded function expression, so a request body reaching it is remote code execution, and it is public, so the app, the rule and the requests can all live in the open while the review rationale and detector recipe stay private in hub. The platform vendors the map, which makes the map an artifact of this package. The ladder fixtures established the pattern and its one weakness: nothing downstream can detect a fixture going stale, because no job over there runs this extractor. So this adds the two things that help. An emitter, run the same way as the ladder one: PS_CANARY_EMIT_DIR=/path/to/back/tests/Fixtures/Canary npx vitest run tests/map/canary-emit and a provenance file naming the commit that produced the artifact — the ladder maps carry that as a hand-maintained line in a README, which is only as fresh as whoever last remembered. The assertions do NOT skip when the emit directory is absent. They run on every suite run, so a change to the app or the extractor that would invalidate a vendored copy fails HERE, with a message about the map, rather than three repositories away as a rule that quietly stopped being generated. They pin the endpoint, the coordinate, the sink, and the flow's tier, role, family and generatability — plus that `recognizedSinkKinds` is non-empty, since an empty list means "no model of this API" and the recipe's gate is `dataflow`, which would make the whole canary a needs-review case. The app is the simplest one that carries the vulnerability, and that is load-bearing rather than lazy: a wrapper, a validation step or a rename would move the flow off `exact-local`, and the canary would then be proving that a review-grade flow generates a rule — a different and weaker claim. `canary-case.ts` holds the app as data because two things consume it and must not drift: the assertions here and the emitter, generated from one definition. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c8127eb commit ed99ca5

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

‎tests/map/canary-case.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* The one app the cross-repo canary runs on.
3+
*
4+
* The canary is a single vertical slice through the whole product claim: a reviewed recipe in hub, a
5+
* verdict and a generated rule in saas, and the engine in this package blocking the exploit — proved as
6+
* one chain rather than three sets of green unit tests. CVE-2017-5941 is the slice, because
7+
* `node-serialize`'s `unserialize` executes an embedded `$$ND_FUNC$$` function expression, so a request
8+
* body reaching it is remote code execution, and because it is public: the app, the rule and the requests
9+
* can all live in the open, while the review rationale and detector recipe stay private in hub.
10+
*
11+
* Deliberately the SIMPLEST app that carries the vulnerability. Nothing here is decoration — a wrapper, a
12+
* validation step or a rename would move the flow off `exact-local` and the canary would then be proving
13+
* that a review-grade flow generates a rule, which is a different and weaker claim.
14+
*
15+
* Exported as data because two things consume it and they must not drift: this package's own suite, which
16+
* asserts what the map SAYS about the app, and the emitter, which writes that map where the platform
17+
* vendors it. Generated from one definition, so a change to the app cannot update one and not the other.
18+
*/
19+
export const CANARY_CASE = {
20+
/** Stable identity, referenced from the emitted artifact and from the platform's fixtures. */
21+
id: 'canary/node-serialize',
22+
cve: 'CVE-2017-5941',
23+
pkg: 'node-serialize',
24+
/** The exact version the advisory covers, so the platform's manifest fixture and this agree. */
25+
pkgVersion: '0.0.4',
26+
name: 'a request body reaches node-serialize.unserialize with nothing in between',
27+
packageJson: {
28+
name: 'canary-node-serialize',
29+
private: true,
30+
dependencies: { express: '4.18.2', 'node-serialize': '0.0.4' },
31+
},
32+
files: {
33+
'src/server.js': `
34+
const express = require("express");
35+
const serialize = require("node-serialize");
36+
const app = express();
37+
38+
app.post("/api/restore", (req, res) => {
39+
// CVE-2017-5941: unserialize() evaluates an embedded function expression, so the request body
40+
// reaching it is remote code execution. Passed straight through — no wrapper, no validation, no
41+
// rename — which is what keeps the flow on the one tier that may be promoted automatically.
42+
const state = serialize.unserialize(req.body.state);
43+
res.json({ restored: state });
44+
});
45+
46+
module.exports = app;
47+
`,
48+
},
49+
/**
50+
* What the chain downstream depends on. Asserted in this package (so a change here fails HERE, with a
51+
* message about the map) and restated by the platform against the emitted document — the same
52+
* two-ends-must-agree split the ladder fixtures use.
53+
*/
54+
expect: {
55+
route: '/api/restore',
56+
method: 'POST',
57+
runtimeParameter: 'post.state',
58+
inputId: 'post:state',
59+
sinkKind: 'eval',
60+
sinkOp: 'unserialize',
61+
argumentRole: 'code',
62+
candidateFamily: 'code-injection',
63+
confidence: 'exact-local',
64+
ruleGeneratable: true,
65+
},
66+
} as const;

‎tests/map/canary-emit.test.ts‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { dirname, join } from 'node:path';
5+
import { execFileSync } from 'node:child_process';
6+
import { buildInputMap } from '../../src/map/index.js';
7+
import { CANARY_CASE } from './canary-case.js';
8+
9+
/**
10+
* Regeneration entry point for the canary map, plus the assertions that make it trustworthy to vendor.
11+
*
12+
* The platform commits this document and evaluates it into a verdict and a generated rule. That makes it
13+
* an artifact of THIS package, and the ladder maps established both the pattern and its one weakness:
14+
* nothing downstream can detect a fixture going stale, because no job over there runs this extractor. So
15+
* the emitter's job is to make regeneration one command and the diff reviewable, and the provenance stamp
16+
* says which commit produced it.
17+
*
18+
* Run:
19+
* PS_CANARY_EMIT_DIR=/path/to/back/tests/Fixtures/Canary npx vitest run tests/map/canary-emit
20+
*
21+
* Skipped otherwise, so a normal suite run neither writes files nor needs a directory. The assertions
22+
* below are NOT skipped — they run on every suite run, so a change to the app or the extractor that would
23+
* invalidate the vendored copy fails here first, with a message about the map rather than about a verdict.
24+
*/
25+
const OUT = process.env.PS_CANARY_EMIT_DIR;
26+
27+
/** Per-machine measurements: no contract, and they would make every regeneration a diff on noise. */
28+
const VOLATILE = ['analysisMs', 'rssBytes', 'peakRssBytes'] as const;
29+
30+
/** The platform's on-disk form. Byte-identical or it diffs. */
31+
const serialize = (document: unknown) => `${JSON.stringify(document, null, 2)}\n`;
32+
33+
async function canaryMap(): Promise<Record<string, any>> {
34+
const dir = mkdtempSync(join(tmpdir(), 'ps-canary-emit-'));
35+
try {
36+
for (const [rel, body] of Object.entries(CANARY_CASE.files)) {
37+
const path = join(dir, rel);
38+
mkdirSync(dirname(path), { recursive: true });
39+
writeFileSync(path, body);
40+
}
41+
writeFileSync(join(dir, 'package.json'), JSON.stringify(CANARY_CASE.packageJson));
42+
43+
const { map, error } = await buildInputMap(dir);
44+
expect(error, 'the canary app must produce a map').toBeUndefined();
45+
const document = map as Record<string, any>;
46+
for (const field of VOLATILE) delete document.coverage[field];
47+
48+
return document;
49+
} finally {
50+
rmSync(dir, { recursive: true, force: true });
51+
}
52+
}
53+
54+
/** Which commit produced the artifact. The only record of how fresh a vendored copy is. */
55+
function provenance(): Record<string, unknown> {
56+
let commit = 'unknown';
57+
try {
58+
commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
59+
} catch { /* not a git checkout — 'unknown' is the honest answer, not a failure */ }
60+
61+
return {
62+
note: 'Generated from the canary app in @patchstack/connect. Do not edit; regenerate.',
63+
case: CANARY_CASE.id,
64+
cve: CANARY_CASE.cve,
65+
package: CANARY_CASE.pkg,
66+
packageVersion: CANARY_CASE.pkgVersion,
67+
producedByCommit: commit,
68+
};
69+
}
70+
71+
describe.skipIf(!OUT)('emitting the canary map for the platform', () => {
72+
it('writes the map and its provenance', async () => {
73+
writeFileSync(join(OUT!, 'cve-2017-5941.map.json'), serialize(await canaryMap()));
74+
writeFileSync(join(OUT!, 'cve-2017-5941.provenance.json'), serialize(provenance()));
75+
76+
console.log(`[canary] wrote cve-2017-5941.map.json and .provenance.json to ${OUT}`);
77+
});
78+
});
79+
80+
describe('the canary map says what the chain downstream depends on', () => {
81+
// Runs always, not only when emitting. The platform grades this document into a verdict and a rule; if
82+
// the extractor stops reporting any of these, the vendored copy silently answers for a different app and
83+
// the failure surfaces three repositories away as a rule that stopped being generated. Failing here
84+
// instead is the entire point of asserting both ends.
85+
it('reports the endpoint, the coordinate and the sink', async () => {
86+
const map = await canaryMap();
87+
const endpoint = map.endpoints[0];
88+
89+
expect(map.version).toBe(3);
90+
expect(endpoint.route).toBe(CANARY_CASE.expect.route);
91+
expect(endpoint.method).toBe(CANARY_CASE.expect.method);
92+
expect(endpoint.inputs.find((i: any) => i.name === 'state')?.runtimeParameter)
93+
.toBe(CANARY_CASE.expect.runtimeParameter);
94+
expect(endpoint.sinks).toEqual(expect.arrayContaining([
95+
expect.objectContaining({
96+
kind: CANARY_CASE.expect.sinkKind,
97+
package: CANARY_CASE.pkg,
98+
op: CANARY_CASE.expect.sinkOp,
99+
}),
100+
]));
101+
});
102+
103+
it('reports a flow the platform may act on', async () => {
104+
const map = await canaryMap();
105+
const flow = map.endpoints[0].flows.find((f: any) => f.sink.package === CANARY_CASE.pkg);
106+
107+
expect(flow).toBeDefined();
108+
expect(flow.inputId).toBe(CANARY_CASE.expect.inputId);
109+
expect(flow.argumentRole).toBe(CANARY_CASE.expect.argumentRole);
110+
expect(flow.candidateFamily).toBe(CANARY_CASE.expect.candidateFamily);
111+
expect(flow.confidence).toBe(CANARY_CASE.expect.confidence);
112+
expect(flow.ruleGeneratable).toBe(CANARY_CASE.expect.ruleGeneratable);
113+
});
114+
115+
it('declares that a dataflow question about the package can be answered', async () => {
116+
// `recognizedSinkKinds: []` would mean "no model of this API", and the recipe's gate is `dataflow` —
117+
// so an empty list turns the whole canary into a case the platform must leave as needs-review.
118+
const map = await canaryMap();
119+
120+
expect(map.imports.find((i: any) => i.package === CANARY_CASE.pkg)?.recognizedSinkKinds).toEqual(['eval']);
121+
});
122+
123+
it('carries the evidence blocks and no per-machine fields', async () => {
124+
// A map missing a block grades LOWER rather than failing — an absent `apiInvocations` reads as "no
125+
// call was seen" — so an emitter that quietly dropped one would produce a fixture that passes as a
126+
// conservative verdict. And a machine-specific number makes every regeneration a diff on noise.
127+
const map = await canaryMap();
128+
129+
for (const field of VOLATILE) expect(map.coverage).not.toHaveProperty(field);
130+
expect(Array.isArray(map.imports)).toBe(true);
131+
expect(Array.isArray(map.apiInvocations)).toBe(true);
132+
expect(map.coverage.importsComplete).toBe(true);
133+
});
134+
135+
it('serializes exactly as the platform stores it', () => {
136+
expect(serialize({ a: 1 })).toBe('{\n "a": 1\n}\n');
137+
});
138+
139+
it('stamps the commit that produced the artifact', () => {
140+
// Without it a vendored map has no age. The ladder fixtures carry this as a hand-maintained line in a
141+
// README, which is only as fresh as the last person who remembered.
142+
expect(provenance().producedByCommit).toMatch(/^[0-9a-f]{40}$/);
143+
});
144+
});

0 commit comments

Comments
 (0)