Files
portal/tools/action-journal.test.mjs
T
Дмитрий e1a6f26c06 fix(router-mentor): close sessionId path-traversal class across M1-M4
Аудит M1-M4 (audit-context-building) нашёл непоследовательность guard формы
sessionId: N3-фикс защитил только action-journal.paths() (M1), а 4 sibling-
строителя пути из event.session_id (недоверенный источник) остались без проверки.

Единый экспорт assertSafeSessionId (action-journal.mjs, переиспользует SESSION_ID_RE
N3) применён во всех точках машин:
- M1 action-journal.paths() — рефактор на общий guard (поведение N3 сохранено)
- M4 judge-subrun-journal.paths() — guard добавлен (канал прилежности судьи F1)
- M2 plan-lock.planPath + artifactPath — guard добавлен
- M2 enforce-supreme-gate — экспортируемый guarded stepStatePath, применён в main()

TDD RED-GREEN на каждом файле. Регрессия tools-only 2540 passed + 2 skip (+10).
Серьёзность класса — низкая / защита-в-глубину (sessionId harness-controlled),
закрыт ради консистентности (был 1 из 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:41:07 +03:00

186 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tools/action-journal.test.mjs
import { describe, it, expect } from 'vitest';
import {
GENESIS_HASH, computeEntryHash, appendEntry, verifyChain,
journalAppend, loadJournal, assertSafeSessionId,
} from './action-journal.mjs';
import { verifyReceipt } from './receipt-sign.mjs';
const KEY = 'journal-test-key';
// N3-shared (2026-06-07 аудит M1-M4): единый экспортируемый guard формы sessionId,
// общий для всех sibling-строителей пути машин (action-journal/judge-subrun-journal/
// plan-lock/enforce-supreme-gate). Та же форма /^[A-Za-z0-9_-]+$/ + throw (fail-closed).
describe('assertSafeSessionId (общий guard формы sessionId)', () => {
it('нормальный UUID-подобный id проходит и возвращается', () => {
expect(assertSafeSessionId('253c4625-762f-4f3e-9e15-ae5a25e498ea')).toBe('253c4625-762f-4f3e-9e15-ae5a25e498ea');
expect(assertSafeSessionId('unknown')).toBe('unknown');
});
it('traversal/слэш/точка/обратный слэш/пусто/не-строка → throw', () => {
expect(() => assertSafeSessionId('../evil')).toThrow();
expect(() => assertSafeSessionId('a/b')).toThrow();
expect(() => assertSafeSessionId('a.b')).toThrow();
expect(() => assertSafeSessionId('a\\b')).toThrow();
expect(() => assertSafeSessionId('')).toThrow();
expect(() => assertSafeSessionId(null)).toThrow();
expect(() => assertSafeSessionId(42)).toThrow();
});
});
describe('R-31: голова журнала подписана в своём домене (journal-head)', () => {
it('домен-агностичная проверка отвергает, правильный домен принимает', () => {
const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
const head = { seq: a.entries[0].seq, head_hash: a.entries[0].chain_hash, sig: a.headSig };
expect(verifyReceipt(head, KEY)).toBe(false);
expect(verifyReceipt(head, KEY, 'journal-head')).toBe(true);
});
});
describe('action-journal core', () => {
it('GENESIS_HASH is 64 zeros', () => {
expect(GENESIS_HASH).toBe('0'.repeat(64));
});
it('computeEntryHash is deterministic and 64-char hex', () => {
const h = computeEntryHash(GENESIS_HASH, { action: 'Edit', file: 'a.mjs' });
expect(h).toMatch(/^[0-9a-f]{64}$/);
expect(computeEntryHash(GENESIS_HASH, { action: 'Edit', file: 'a.mjs' })).toBe(h);
});
it('appendEntry links to previous head and signs the head', () => {
const e1 = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1000 });
expect(e1.entries).toHaveLength(1);
expect(e1.entries[0].seq).toBe(1);
expect(e1.entries[0].prev_hash).toBe(GENESIS_HASH);
expect(e1.headSig).toMatch(/^[0-9a-f]{64}$/);
const e2 = appendEntry(e1.entries, { action: 'B' }, { key: KEY, nowMs: 2000 });
expect(e2.entries).toHaveLength(2);
expect(e2.entries[1].prev_hash).toBe(e1.entries[0].chain_hash);
expect(e2.entries[1].seq).toBe(2);
});
it('verifyChain ok for an intact, signed chain', () => {
const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 });
expect(verifyChain(b.entries, b.headSig, { key: KEY })).toEqual({ ok: true, brokenAt: null });
});
it('фикс-B2: подмена ts или seq промежуточной записи ломает цепь (раньше проскакивала)', () => {
const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 });
const tamperedTs = b.entries.map((e, i) => i === 0 ? { ...e, ts: 999999 } : e);
expect(verifyChain(tamperedTs, b.headSig, { key: KEY }).ok).toBe(false);
const tamperedSeq = b.entries.map((e, i) => i === 0 ? { ...e, seq: 99 } : e);
expect(verifyChain(tamperedSeq, b.headSig, { key: KEY }).ok).toBe(false);
});
it('verifyChain detects a tampered entry (rewrite history)', () => {
const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 });
const tampered = b.entries.map((e, i) => i === 0 ? { ...e, payload: { action: 'EVIL' } } : e);
const r = verifyChain(tampered, b.headSig, { key: KEY });
expect(r.ok).toBe(false);
expect(r.brokenAt).toBe(1);
});
it('verifyChain detects wholesale rewrite (recomputed chain, forged head) without key', () => {
const a = appendEntry([], { action: 'EVIL' }, { key: 'attacker-key', nowMs: 1 });
const r = verifyChain(a.entries, a.headSig, { key: KEY });
expect(r.ok).toBe(false);
});
it('verifyChain false when head signature missing', () => {
const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
expect(verifyChain(a.entries, null, { key: KEY }).ok).toBe(false);
});
});
function memFs() {
const store = new Map();
return {
store,
readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); },
writeFileSync: (p, data) => { store.set(String(p), String(data)); },
appendFileSync: (p, data) => { store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(data)); },
existsSync: (p) => store.has(String(p)),
};
}
describe('journalAppend persistence', () => {
const opts = (fs) => ({ key: KEY, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
it('writes a JSONL line and a head-sig file', () => {
const fs = memFs();
const r = journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) });
expect(r.entry.seq).toBe(1);
expect(fs.store.get('/rt/action-journal-S1.jsonl')).toContain('"action":"A"');
expect(fs.store.get('/rt/action-journal-S1.head')).toBe(r.headSig);
});
it('appends a second entry linked to the first', () => {
const fs = memFs();
journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) });
const r2 = journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) });
expect(r2.entry.seq).toBe(2);
const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
expect(loaded.entries).toHaveLength(2);
expect(loaded.entries[1].prev_hash).toBe(loaded.entries[0].chain_hash);
});
it('loadJournal returns empty for a missing file', () => {
const fs = memFs();
expect(loadJournal({ sessionId: 'none', runtimeDir: '/rt', fsImpl: fs })).toEqual({ entries: [], headSig: null });
});
it('round-trip verifyChain on persisted-then-loaded journal', () => {
const fs = memFs();
journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) });
const r2 = journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) });
const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
expect(verifyChain(loaded.entries, r2.headSig, { key: KEY }).ok).toBe(true);
});
});
// N2 (2026-06-07 аудит M1): verifyChain должен вырождаться в {ok:false}, а не бросать,
// на структурно-битой записи (null / не-объект / массив). Сценарий: внешняя порча
// .jsonl строкой `null` → loadJournal возвращает [null] БЕЗ исключения → verifyChain
// раньше падал TypeError на e.prev_hash. Fail-closed value вместо краша.
describe('N2: verifyChain fail-closed на битой записи (не бросает)', () => {
it('одиночная null-запись → {ok:false}, без исключения', () => {
expect(() => verifyChain([null], '0'.repeat(64), { key: KEY })).not.toThrow();
expect(verifyChain([null], '0'.repeat(64), { key: KEY }).ok).toBe(false);
});
it('не-объектные записи (число / строка / массив) → {ok:false}, без исключения', () => {
expect(verifyChain([123], '0'.repeat(64), { key: KEY }).ok).toBe(false);
expect(verifyChain(['str'], '0'.repeat(64), { key: KEY }).ok).toBe(false);
expect(verifyChain([[1, 2]], '0'.repeat(64), { key: KEY }).ok).toBe(false);
});
it('null в середине валидной цепи → {ok:false}, без исключения', () => {
const a = appendEntry([], { action: 'A' }, { key: KEY, nowMs: 1 });
const b = appendEntry(a.entries, { action: 'B' }, { key: KEY, nowMs: 2 });
const withNull = [b.entries[0], null];
expect(() => verifyChain(withNull, b.headSig, { key: KEY })).not.toThrow();
expect(verifyChain(withNull, b.headSig, { key: KEY }).ok).toBe(false);
});
});
// N3 (2026-06-07 аудит M1): paths() вклеивал sessionId в путь файла без проверки формы.
// `..`/`/` → path traversal (журнал пишется/читается вне каталога). Guard /^[A-Za-z0-9_-]+$/
// → throw (fail-closed; в supreme-gate ловится внешним try/catch → block).
describe('N3: sessionId path-injection guard', () => {
it('journalAppend с traversal-sessionId бросает (не пишет вне каталога)', () => {
const fs = memFs();
expect(() => journalAppend({ payload: { a: 1 }, key: KEY, sessionId: '../evil', runtimeDir: '/rt', nowMs: 1, fsImpl: fs })).toThrow();
expect(fs.store.size).toBe(0);
});
it('loadJournal с traversal-sessionId бросает', () => {
const fs = memFs();
expect(() => loadJournal({ sessionId: '../../etc/passwd', runtimeDir: '/rt', fsImpl: fs })).toThrow();
});
it('sessionId со слэшем/точкой/обратным слэшем бросает', () => {
const fs = memFs();
expect(() => loadJournal({ sessionId: 'a/b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(() => loadJournal({ sessionId: 'a.b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(() => loadJournal({ sessionId: 'a\\b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
});
it('нормальный UUID-подобный sessionId работает', () => {
const fs = memFs();
const r = journalAppend({ payload: { a: 1 }, key: KEY, sessionId: '253c4625-762f-4f3e-9e15-ae5a25e498ea', runtimeDir: '/rt', nowMs: 1, fsImpl: fs });
expect(r.entry.seq).toBe(1);
});
});