sec-kn-2-server-only-evaluation.test.mjs
483 lines 17.0 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SEC-KN-2 — seven-tier coverage for server-only evaluation fields on proposal create.
3 *
4 * Frozen requirement: Pass 2 P2
5 * (`~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md`) —
6 * client-supplied evaluation_status / evaluated_by / evaluated_at must be stripped
7 * from all create bodies; only server-side evaluation may set them.
8 *
9 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
10 */
11
12 import { test, describe } from 'node:test';
13 import assert from 'node:assert/strict';
14 import fs from 'node:fs';
15 import os from 'node:os';
16 import path from 'node:path';
17 import { performance } from 'node:perf_hooks';
18 import { fileURLToPath } from 'node:url';
19 import {
20 augmentProposalCreateRequestBody,
21 stripClientEvaluationFields,
22 CLIENT_EVALUATION_CREATE_FIELDS,
23 } from '../lib/hub-proposal-create-augment.mjs';
24 import {
25 applyPersonalSelfApplyEvaluationE1,
26 SCOOLING_REVIEW_TRAY_INTENT,
27 } from '../lib/hub-proposal-personal-self-apply.mjs';
28 import { augmentProposalCreateForHosted } from '../hub/gateway/proposal-create-hosted-body.mjs';
29
30 const __dirname = path.dirname(fileURLToPath(import.meta.url));
31 const ROOT = path.resolve(__dirname, '..');
32 const AUGMENT_SRC = path.join(ROOT, 'lib/hub-proposal-create-augment.mjs');
33 const E1_SRC = path.join(ROOT, 'lib/hub-proposal-personal-self-apply.mjs');
34 const MAIN_MO = path.join(ROOT, 'hub/icp/src/hub/main.mo');
35
36 /**
37 * Pre-fix augment behavior (Pass 2 P2) — only fills pending when empty.
38 * Security tier asserts current code diverges from this on forged `passed`.
39 *
40 * @param {Record<string, unknown>} body
41 * @param {{ evaluationRequired?: boolean }} [opts]
42 * @returns {Record<string, unknown>}
43 */
44 function augmentProposalCreateFailOpenLegacy(body, opts = {}) {
45 if (!body || typeof body !== 'object') return body;
46 let next = { ...body };
47 const needPending = opts.evaluationRequired === true;
48 if (needPending) {
49 const es = next.evaluation_status;
50 if (es == null || String(es).trim() === '') next.evaluation_status = 'pending';
51 }
52 return next;
53 }
54
55 function matchingFingerprint(overrides = {}) {
56 return {
57 path: 'reviewed/review-sec-kn-2.md',
58 body: '# Note\n',
59 intent: SCOOLING_REVIEW_TRAY_INTENT,
60 external_ref: 'scooling.review:sec-kn-2',
61 labels: [],
62 ...overrides,
63 };
64 }
65
66 function mkDataDir() {
67 return fs.mkdtempSync(path.join(os.tmpdir(), 'kt-sec-kn-2-'));
68 }
69
70 // ---------------------------------------------------------------------------
71 // Tier 1 — unit
72 // ---------------------------------------------------------------------------
73 describe('SEC-KN-2 unit — strip client evaluation fields', () => {
74 test('stripClientEvaluationFields removes all three create fields', () => {
75 const stripped = stripClientEvaluationFields({
76 path: 'inbox/a.md',
77 evaluation_status: 'passed',
78 evaluated_by: 'attacker',
79 evaluated_at: '2099-01-01T00:00:00.000Z',
80 body: 'x',
81 });
82 assert.equal(stripped.path, 'inbox/a.md');
83 assert.equal(stripped.body, 'x');
84 for (const key of CLIENT_EVALUATION_CREATE_FIELDS) {
85 assert.equal(Object.hasOwn(stripped, key), false, `must strip ${key}`);
86 }
87 });
88
89 test('strip does not mutate the input object', () => {
90 const input = { evaluation_status: 'passed', path: 'n.md' };
91 const out = stripClientEvaluationFields(input);
92 assert.equal(input.evaluation_status, 'passed');
93 assert.notEqual(out, input);
94 assert.equal(Object.hasOwn(out, 'evaluation_status'), false);
95 });
96
97 test('non-fingerprint forge + gate on → pending, no attacker evaluated_by', () => {
98 const dir = mkDataDir();
99 try {
100 const body = augmentProposalCreateRequestBody(
101 {
102 path: 'inbox/forged.md',
103 body: 'x',
104 intent: 'other',
105 external_ref: 'scooling.review:x',
106 evaluation_status: 'passed',
107 evaluated_by: 'attacker',
108 evaluated_at: '2099-01-01T00:00:00.000Z',
109 labels: [],
110 },
111 dir,
112 { evaluationRequired: true, evaluatedBy: 'server:actor' },
113 );
114 assert.equal(body.evaluation_status, 'pending');
115 assert.equal(Object.hasOwn(body, 'evaluated_by'), false);
116 assert.equal(Object.hasOwn(body, 'evaluated_at'), false);
117 } finally {
118 fs.rmSync(dir, { recursive: true, force: true });
119 }
120 });
121
122 test('E1 ignores body.evaluated_by; only audit sets auditor', () => {
123 const out = applyPersonalSelfApplyEvaluationE1(
124 {
125 ...matchingFingerprint(),
126 evaluation_status: 'pending',
127 evaluated_by: 'forged-client',
128 },
129 { evaluatedBy: 'server:learner', evaluatedAt: '2026-07-26T12:00:00.000Z' },
130 );
131 assert.equal(out.evaluation_status, 'passed');
132 assert.equal(out.evaluated_by, 'server:learner');
133 assert.equal(out.evaluated_at, '2026-07-26T12:00:00.000Z');
134 });
135
136 test('E1 without audit does not keep client evaluated_by', () => {
137 const out = applyPersonalSelfApplyEvaluationE1({
138 ...matchingFingerprint(),
139 evaluation_status: 'pending',
140 evaluated_by: 'forged-client',
141 });
142 assert.equal(out.evaluation_status, 'passed');
143 assert.equal(Object.hasOwn(out, 'evaluated_by'), false);
144 assert.ok(out.evaluated_at);
145 });
146 });
147
148 // ---------------------------------------------------------------------------
149 // Tier 2 — integration (gateway wrapper + Motoko approve gate contract)
150 // ---------------------------------------------------------------------------
151 describe('SEC-KN-2 integration — hosted create path + Motoko gate', () => {
152 test('augmentProposalCreateForHosted strips forge on POST /api/v1/proposals', () => {
153 const dir = mkDataDir();
154 try {
155 const body = augmentProposalCreateForHosted(
156 'POST',
157 '/api/v1/proposals',
158 {
159 path: 'inbox/x.md',
160 body: 'x',
161 intent: 'agent.suggest',
162 evaluation_status: 'passed',
163 evaluated_by: 'attacker',
164 evaluated_at: '2099-01-01T00:00:00.000Z',
165 labels: [],
166 },
167 dir,
168 { evaluationRequired: true },
169 );
170 assert.equal(body.evaluation_status, 'pending');
171 assert.equal(Object.hasOwn(body, 'evaluated_by'), false);
172 assert.equal(Object.hasOwn(body, 'evaluated_at'), false);
173 } finally {
174 fs.rmSync(dir, { recursive: true, force: true });
175 }
176 });
177
178 test('non-POST / wrong path leave body untouched (no silent strip elsewhere)', () => {
179 const dir = mkDataDir();
180 try {
181 const raw = {
182 path: 'inbox/x.md',
183 evaluation_status: 'passed',
184 evaluated_by: 'attacker',
185 };
186 const get = augmentProposalCreateForHosted('GET', '/api/v1/proposals', raw, dir, {
187 evaluationRequired: true,
188 });
189 assert.equal(get.evaluation_status, 'passed');
190 const other = augmentProposalCreateForHosted('POST', '/api/v1/notes', raw, dir, {
191 evaluationRequired: true,
192 });
193 assert.equal(other.evaluation_status, 'passed');
194 } finally {
195 fs.rmSync(dir, { recursive: true, force: true });
196 }
197 });
198
199 test('Motoko evalStatusAllowsApprove still treats passed as approvable (server must not forge)', () => {
200 const src = fs.readFileSync(MAIN_MO, 'utf8');
201 assert.ok(src.includes('func evalStatusAllowsApprove'));
202 assert.ok(src.includes('es == "passed"'));
203 // Create path still reads evaluation_status from body — trusted only after gateway strip.
204 assert.ok(src.includes('extractJsonString(bodyText, "evaluation_status")'));
205 });
206
207 test('fingerprint class still receives server E1 passed via hosted augment', () => {
208 const dir = mkDataDir();
209 try {
210 const body = augmentProposalCreateForHosted(
211 'POST',
212 '/api/v1/proposals',
213 {
214 ...matchingFingerprint(),
215 evaluation_status: 'failed',
216 evaluated_by: 'attacker',
217 },
218 dir,
219 { evaluationRequired: true, evaluatedBy: 'google:member1' },
220 );
221 assert.equal(body.evaluation_status, 'passed');
222 assert.equal(body.evaluated_by, 'google:member1');
223 } finally {
224 fs.rmSync(dir, { recursive: true, force: true });
225 }
226 });
227 });
228
229 // ---------------------------------------------------------------------------
230 // Tier 3 — e2e (create-body matrix → approve eligibility inputs)
231 // ---------------------------------------------------------------------------
232 describe('SEC-KN-2 e2e — forge matrix through augment', () => {
233 /** @returns {{ status: string, evaluation_status: string, evaluated_by?: string }} */
234 function simulateCreate(clientBody, opts) {
235 const dir = mkDataDir();
236 try {
237 const augmented = augmentProposalCreateRequestBody(clientBody, dir, opts);
238 return {
239 status: 'proposed',
240 evaluation_status: String(augmented.evaluation_status ?? ''),
241 ...(augmented.evaluated_by != null ? { evaluated_by: String(augmented.evaluated_by) } : {}),
242 intent: String(augmented.intent ?? ''),
243 };
244 } finally {
245 fs.rmSync(dir, { recursive: true, force: true });
246 }
247 }
248
249 test('non-class forged passed under gate → pending (approve would need real evaluation)', () => {
250 const row = simulateCreate(
251 {
252 path: 'meta/tasks/proposals/t1.json',
253 body: '{}',
254 intent: 'task.create',
255 evaluation_status: 'passed',
256 evaluated_by: 'attacker',
257 labels: [],
258 },
259 { evaluationRequired: true, evaluatedBy: 'attacker' },
260 );
261 assert.equal(row.evaluation_status, 'pending');
262 assert.equal(row.evaluated_by, undefined);
263 });
264
265 test('gate off: non-class forge still stripped (no silent passed)', () => {
266 const row = simulateCreate(
267 {
268 path: 'inbox/x.md',
269 body: 'x',
270 intent: 'other',
271 evaluation_status: 'passed',
272 evaluated_by: 'attacker',
273 labels: [],
274 },
275 { evaluationRequired: false },
276 );
277 assert.notEqual(row.evaluation_status, 'passed');
278 assert.equal(row.evaluated_by, undefined);
279 });
280
281 test('class path: client forge replaced by server actor', () => {
282 const row = simulateCreate(
283 {
284 ...matchingFingerprint(),
285 evaluation_status: 'passed',
286 evaluated_by: 'attacker',
287 },
288 { evaluationRequired: true, evaluatedBy: 'google:real-learner' },
289 );
290 assert.equal(row.evaluation_status, 'passed');
291 assert.equal(row.evaluated_by, 'google:real-learner');
292 });
293 });
294
295 // ---------------------------------------------------------------------------
296 // Tier 4 — stress
297 // ---------------------------------------------------------------------------
298 describe('SEC-KN-2 stress — many forged create bodies', () => {
299 test('5_000 forged non-class creates stay pending under gate', () => {
300 const dir = mkDataDir();
301 try {
302 for (let i = 0; i < 5_000; i++) {
303 const body = augmentProposalCreateRequestBody(
304 {
305 path: `inbox/f-${i}.md`,
306 body: `x${i}`,
307 intent: 'other',
308 evaluation_status: i % 2 === 0 ? 'passed' : 'failed',
309 evaluated_by: `attacker-${i}`,
310 evaluated_at: '2099-01-01T00:00:00.000Z',
311 labels: [],
312 },
313 dir,
314 { evaluationRequired: true },
315 );
316 assert.equal(body.evaluation_status, 'pending');
317 assert.equal(Object.hasOwn(body, 'evaluated_by'), false);
318 }
319 } finally {
320 fs.rmSync(dir, { recursive: true, force: true });
321 }
322 });
323 });
324
325 // ---------------------------------------------------------------------------
326 // Tier 5 — data-integrity
327 // ---------------------------------------------------------------------------
328 describe('SEC-KN-2 data-integrity — idempotent strip + no client audit bleed', () => {
329 test('same forged inputs always yield same server evaluation_status', () => {
330 const dir = mkDataDir();
331 try {
332 const input = {
333 path: 'inbox/same.md',
334 body: 'x',
335 intent: 'other',
336 evaluation_status: 'passed',
337 evaluated_by: 'attacker',
338 labels: [],
339 };
340 const a = augmentProposalCreateRequestBody(input, dir, { evaluationRequired: true });
341 const b = augmentProposalCreateRequestBody(input, dir, { evaluationRequired: true });
342 assert.equal(a.evaluation_status, b.evaluation_status);
343 assert.equal(a.evaluation_status, 'pending');
344 assert.equal(Object.hasOwn(a, 'evaluated_by'), false);
345 assert.equal(Object.hasOwn(b, 'evaluated_by'), false);
346 } finally {
347 fs.rmSync(dir, { recursive: true, force: true });
348 }
349 });
350
351 test('source files document strip-before-assign contract', () => {
352 const augment = fs.readFileSync(AUGMENT_SRC, 'utf8');
353 const e1 = fs.readFileSync(E1_SRC, 'utf8');
354 assert.ok(augment.includes('stripClientEvaluationFields'));
355 assert.ok(augment.includes('SEC-KN-2'));
356 assert.ok(e1.includes('SEC-KN-2'));
357 assert.ok(!e1.includes('typeof body.evaluated_by === \'string\''));
358 });
359 });
360
361 // ---------------------------------------------------------------------------
362 // Tier 6 — performance
363 // ---------------------------------------------------------------------------
364 describe('SEC-KN-2 performance — bounded augment time', () => {
365 test('20k forged augments complete under 2s', () => {
366 const dir = mkDataDir();
367 try {
368 const t0 = performance.now();
369 for (let i = 0; i < 20_000; i++) {
370 augmentProposalCreateRequestBody(
371 {
372 path: 'inbox/p.md',
373 body: 'x',
374 intent: 'other',
375 evaluation_status: 'passed',
376 evaluated_by: 'attacker',
377 labels: [],
378 },
379 dir,
380 { evaluationRequired: true },
381 );
382 }
383 const ms = performance.now() - t0;
384 assert.ok(ms < 2000, `expected <2000ms, got ${ms.toFixed(1)}ms`);
385 } finally {
386 fs.rmSync(dir, { recursive: true, force: true });
387 }
388 });
389 });
390
391 // ---------------------------------------------------------------------------
392 // Tier 7 — security (regression must FAIL against pre-fix client-forgeable behavior)
393 // ---------------------------------------------------------------------------
394 describe('SEC-KN-2 security — regression vs client-forgeable evaluation', () => {
395 test('security regression: forged passed must not survive gate (legacy would keep it)', () => {
396 const forged = {
397 path: 'inbox/x.md',
398 body: 'x',
399 intent: 'task.create',
400 evaluation_status: 'passed',
401 evaluated_by: 'attacker',
402 evaluated_at: '2099-01-01T00:00:00.000Z',
403 labels: [],
404 };
405 const legacy = augmentProposalCreateFailOpenLegacy(forged, { evaluationRequired: true });
406 assert.equal(
407 legacy.evaluation_status,
408 'passed',
409 'sanity: legacy helper still models client-forgeable passed under gate',
410 );
411 assert.equal(legacy.evaluated_by, 'attacker');
412
413 const dir = mkDataDir();
414 try {
415 const fixed = augmentProposalCreateRequestBody(forged, dir, {
416 evaluationRequired: true,
417 evaluatedBy: 'attacker',
418 });
419 assert.equal(fixed.evaluation_status, 'pending');
420 assert.notEqual(
421 fixed.evaluation_status,
422 legacy.evaluation_status,
423 'fixed behavior must diverge from pre-fix forge-preserving augment',
424 );
425 assert.equal(Object.hasOwn(fixed, 'evaluated_by'), false);
426 assert.equal(Object.hasOwn(fixed, 'evaluated_at'), false);
427 } finally {
428 fs.rmSync(dir, { recursive: true, force: true });
429 }
430 });
431
432 test('gate off: legacy keeps client passed; fixed strips it', () => {
433 const forged = {
434 path: 'inbox/x.md',
435 body: 'x',
436 intent: 'other',
437 evaluation_status: 'passed',
438 evaluated_by: 'attacker',
439 labels: [],
440 };
441 const legacy = augmentProposalCreateFailOpenLegacy(forged, { evaluationRequired: false });
442 assert.equal(legacy.evaluation_status, 'passed');
443
444 const dir = mkDataDir();
445 try {
446 const fixed = augmentProposalCreateRequestBody(forged, dir, { evaluationRequired: false });
447 assert.notEqual(fixed.evaluation_status, 'passed');
448 assert.equal(Object.hasOwn(fixed, 'evaluated_by'), false);
449 } finally {
450 fs.rmSync(dir, { recursive: true, force: true });
451 }
452 });
453
454 test('server E1 is the only create path that may set passed for fingerprint class', () => {
455 const dir = mkDataDir();
456 try {
457 const forgedNonClass = augmentProposalCreateRequestBody(
458 {
459 path: 'reviewed/looks-like.md',
460 body: 'x',
461 intent: 'other',
462 external_ref: 'scooling.review:looks',
463 evaluation_status: 'passed',
464 evaluated_by: 'attacker',
465 labels: [],
466 },
467 dir,
468 { evaluationRequired: true, evaluatedBy: 'attacker' },
469 );
470 assert.equal(forgedNonClass.evaluation_status, 'pending');
471
472 const classOk = augmentProposalCreateRequestBody(
473 matchingFingerprint({ evaluation_status: 'passed', evaluated_by: 'attacker' }),
474 dir,
475 { evaluationRequired: true, evaluatedBy: 'google:learner' },
476 );
477 assert.equal(classOk.evaluation_status, 'passed');
478 assert.equal(classOk.evaluated_by, 'google:learner');
479 } finally {
480 fs.rmSync(dir, { recursive: true, force: true });
481 }
482 });
483 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago