// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * PLAN §12 cross-persona abuse sequence: a power-user state (tokens, revocations, audit history * across restarts, a large container set) is attacked by malicious and fuzzer traffic — HTTP * enumeration, near-matches, oracle-checked fuzz requests, malformed requests, failing CLI * garbage and a foreign write lock, all at the same time — then the server is killed (SIGKILL) * in the middle of a burst, restarted on the same database, and finally administered normally. * Recovery and integrity are verified against the model after every phase. */ import { test, expect, expectCliFailure, errorMessage, expectContainersResponse, expectErrorResponse, mapLimit, pollUntil, formatAuditOutput, readOperations, type CliResult, type Rng, } from '../../harness'; import { DatabaseSync } from 'node:sqlite'; import { StateModel, TOKEN_CHARS, authOf, checkResponse, cliOutcome, configOutcome, expectHttp, expectNoTokenFragments, fuzzAddr, fuzzLogLevel, fuzzPort, fuzzRequest, fuzzText, malformedRequest, matchesConfigError, nearMatches, onlyResponse, rawBytes, stateDigest, AUDIT_ALL_ARGV_NEAR_MISSES, HELP_NEAR_MISSES, LIST_ALL_NEAR_MISSES, expectedAuditStdout, type AppEnv, } from '../../harness/personas'; /** Failing CLI invocations only (the attacker cannot change state through the CLI without a valid token argument). */ function garbageCli(r: Rng, model: StateModel): { args: string[]; env: AppEnv; addrValid?: boolean } { for (;;) { const kind = r.pick(['usage', 'usage', 'delete', 'delete', 'config']); if (kind === 'config') { const a = fuzzAddr(r); const env: AppEnv = r.bool() ? { HTTP_ADDR: a.value } : r.bool() ? { HTTP_PORT: fuzzPort(r) } : { LOG_LEVEL: fuzzLogLevel(r) }; const cfg = configOutcome(env, env.HTTP_ADDR === undefined ? undefined : a.valid); if (!cfg.ok) return { args: ['token', 'list'], env, addrValid: env.HTTP_ADDR === undefined ? undefined : a.valid }; continue; } const args = kind === 'usage' ? r.pick([['token'], ['tokens'], ['token', 'nwe'], ['token', 'list', fuzzText(r, { maxLen: 64 }).value], ['token', 'new', 'a', fuzzText(r, { maxLen: 64 }).value], ['token', 'delete'], [fuzzText(r, { maxLen: 64 }).value], [...r.pick(HELP_NEAR_MISSES)], [...r.pick(LIST_ALL_NEAR_MISSES)], [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES)]]) : ['token', 'delete', r.pick([...nearMatches(r.pick(model.all()), r, 2).map((m) => m.value), r.pick(model.inactive().length ? model.inactive() : ['x']), fuzzText(r, { maxLen: 128 }).value, r.string(43, TOKEN_CHARS)])]; const o = cliOutcome(args, model); if (o.kind === 'error') return { args, env: {} }; } } test('power-user state attacked by malicious + fuzzer traffic, a write lock and a SIGKILL mid-burst, then normal administration: full recovery and integrity', async ({ cli, ws, startServer, http, fuzz, secrets, e2e }) => { test.setTimeout(180_000); const r = fuzz.rng.fork('cross'); const model = new StateModel(); e2e.trackedDbs.add(ws.dbPath); // ---- 1. power-user state const descs = Array.from({ length: 40 }, (_, i) => (i % 5 === 0 ? fuzzText(r, { maxLen: 512 }).value : `team-${i} ${r.pick(['ci', 'ops', 'dash'])}`)); (await mapLimit(descs, 8, (d) => cli.tokenNew(d))).forEach((t, i) => model.add(t, descs[i])); for (const t of r.shuffle(model.all()).slice(0, 10)) { await cli.tokenDelete(t); model.deactivate(t); } const server = await startServer({ logLevel: 'DEBUG' }); const fake = server.fakeDocker!.many(300, r.fork('containers')); for (let round = 0; round < 3; round++) { const users = r.shuffle(model.active()).slice(0, 15); const plan = Array.from({ length: 100 }, (_, i) => users[i % users.length]); await mapLimit(plan, 16, async (t) => { expect(expectContainersResponse(await http.get(server.url(), { token: t }))).toHaveLength(300); }); for (const t of plan) model.audit(t); if (round < 2) await server.restart(); } model.verifyDb(ws.dbPath, 'power-user state'); const lookup = (t: string) => model.lookup(t); // ---- 2. attack: everything at once await test.step('concurrent attack: enumeration, near-matches, fuzz, malformed requests, CLI garbage and a 2 s foreign write lock', async () => { const audits: string[] = []; const host = { host: server.connectHost, port: server.port }; const enumeration = mapLimit(Array.from({ length: 400 }, () => r.string(43, TOKEN_CHARS)), 8, async (g) => { expectErrorResponse(await http.get(server.url(), { token: g }), 401); }); const nearCases = model.all().flatMap((t) => nearMatches(t, r, 6)).filter((m) => !/[\r\n]/.test(m.value)); const near = mapLimit(nearCases, 6, async (m) => { const header = `Bearer ${m.value}`; const exp = authOf([header], lookup); const res = onlyResponse(await http.raw({ ...host, data: Buffer.from(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: ${header}\r\nConnection: close\r\n\r\n`, 'utf8'), timeoutMs: 20_000 }), m.kind); if (exp.cls === 'ok') { expectContainersResponse(res); audits.push(exp.token!); } else expectErrorResponse(res, 401); }); const fuzzCases = Array.from({ length: 300 }, (_, i) => (i % 8 === 0 ? malformedRequest(r, model) : fuzzRequest(r, model))); const fuzzing = mapLimit(fuzzCases, 6, async (c, i) => { const exp = expectHttp(c, lookup, true); fuzz.record({ phase: 'attack', i, method: c.method, target: c.target.slice(0, 200), headers: c.headers.map(([k, v]) => [k, v.slice(0, 200)]), expected: exp }); const res = onlyResponse(await http.raw({ ...host, data: rawBytes(c, { keepAlive: c.body !== undefined }), head: c.method === 'HEAD', timeoutMs: 20_000 }), `fuzz ${i}`); checkResponse(res, exp, { head: c.method === 'HEAD', containers: 300, secrets, label: `fuzz ${i}` }); if (exp.audited && res.status === 200) audits.push(exp.audited); }); // every 10th garbage invocation is a near miss of `audit all` (`audit ALL`, `audit all x`, ...) const garbageCases = Array.from({ length: 60 }, (_, i) => (i % 10 === 3 ? { args: [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES.filter((a) => /^audit$/i.test(a[0])))], env: {} as AppEnv, addrValid: undefined } : garbageCli(r, model))); const auditAllMisses = garbageCases.filter((g) => !Object.keys(g.env).length && g.args.some((a) => /[a\u0430]ll/i.test(a)) && /^audit$/i.test(g.args[0])).length; expect(auditAllMisses, 'the garbage includes near misses of audit all').toBeGreaterThanOrEqual(6); const garbage = mapLimit(garbageCases, 4, async (g, i) => { fuzz.record({ phase: 'cli-garbage', i, args: g.args.map((a) => a.slice(0, 120)), env: g.env }); const res: CliResult = await cli.run(g.args, { env: g.env }); if (Object.keys(g.env).length) { const cfg = configOutcome(g.env, g.addrValid); expect(cfg.ok).toBe(false); expectCliFailure(res, { exitCode: 2, usage: false }); expect(matchesConfigError(errorMessage(res), cfg as Exclude), `cli garbage ${i}: ${errorMessage(res)}`).toBe(true); } else { const o = cliOutcome(g.args, model); if (o.kind !== 'error') throw new Error('garbage generator produced a successful command'); expectCliFailure(res, { exitCode: o.exitCode, message: o.message, usage: o.usage }); } }); const lock = (async () => { await pollUntil('attack traffic under way', () => (fuzz.cases.length > 100 ? true : undefined), { timeoutMs: 30_000, intervalMs: 5 }); const db = new DatabaseSync(ws.dbPath, { timeout: 3000 }); db.exec('BEGIN IMMEDIATE'); await new Promise((res) => setTimeout(res, 2000)); db.exec('ROLLBACK'); db.close(); })(); await Promise.all([enumeration, near, fuzzing, garbage, lock]); for (const t of audits) model.audit(t); model.verifyDb(ws.dbPath, 'after the attack: only legitimately authenticated requests left a trace'); }); // ---- 3. crash in the middle of a burst, restart on the same database await test.step('SIGKILL during a burst of 80 authenticated requests, then restart: committed rows survive, nothing is half-written', async () => { const victim = model.active()[0]; const before = readOperations(ws.dbPath).filter((o) => o.token === victim).length; fake.delay(5); // keep requests in flight let done = 0; const burst = mapLimit(Array.from({ length: 80 }, () => 0), 10, async () => { const res = await http.raw({ host: server.connectHost, port: server.port, data: rawBytes({ cls: 'burst', method: 'GET', target: '/api/v1/containers', headers: [['Authorization', `Bearer ${victim}`]] }), timeoutMs: 20_000 }); done++; return res; }); await pollUntil('a third of the burst answered', () => (done >= 25 ? true : undefined), { timeoutMs: 30_000, intervalMs: 2 }); const killed = await server.kill(); expect(killed.signal).toBe('SIGKILL'); const results = await burst; const ok = results.filter((x) => x.outcome === 'response' && x.response!.status === 200).length; const lost = results.filter((x) => x.outcome !== 'response').length; expect(results.every((x) => x.outcome !== 'response' || x.response!.status === 200), 'answered requests all succeeded').toBe(true); expect(ok + lost).toBe(80); expect(lost, 'the kill really interrupted the burst').toBeGreaterThan(0); fake.delay(0); await server.restart(); // new process, same DB/port/backend: WAL recovery const after = readOperations(ws.dbPath).filter((o) => o.token === victim).length - before; // every answered request is durably audited; interrupted ones may or may not have committed their row expect(after).toBeGreaterThanOrEqual(ok); expect(after).toBeLessThanOrEqual(ok + lost); model.audit(victim, after); model.verifyDb(ws.dbPath, 'after crash recovery'); // `audit all` on the recovered database: every committed row, oldest first, byte-exact const all = await cli.audit('all'); expect(all, 'audit all after recovery: every row').toHaveLength(model.auditTotal); expect(formatAuditOutput(all)).toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true })); test.info().annotations.push({ type: 'crash', description: `${ok} answered, ${lost} interrupted, ${after - ok} interrupted request(s) had committed their audit row` }); }); // ---- 4. normal administration on the recovered instance await test.step('normal administration: list, create, rotate, revoke, use with every client, graceful restart', async () => { model.verifyList(await cli.tokenList()); model.verifyList(await cli.tokenListAll(), 'token list all after the incident', 'all'); for (let i = 0; i < 5; i++) model.add(await cli.tokenNew(`after incident ${i}`), `after incident ${i}`); for (let i = 0; i < 3; i++) { const old = r.pick(model.active()); const next = model.add(await cli.tokenNew(`rotated ${i}`), `rotated ${i}`).token; await cli.tokenDelete(old); model.deactivate(old); expectErrorResponse(await http.get(server.url(), { token: old }), 401); expectContainersResponse(await http.curl({ url: server.url(), token: next })); model.audit(next); } for (const t of model.active()) { expectContainersResponse(await http.get(server.url(), { token: t })); model.audit(t); } const listing = (await cli.run(['token', 'list', 'all'])).stdout; const history = (await cli.run(['audit', 'all'])).stdout; expect(history, 'audit all: the whole history').toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true })); await server.restart(); expect((await cli.run(['token', 'list', 'all'])).stdout).toBe(listing); expect((await cli.run(['audit', 'all'])).stdout, 'audit all survives a graceful restart').toBe(history); for (const t of model.inactive()) expectErrorResponse(await http.get(server.url(), { token: t }), 401); }); expect((await server.stop()).exitCode).toBe(0); for (const g of server.allOutput()) expectNoTokenFragments(g.stderr, secrets, `server generation ${g.generation}`); expect(server.logs().count({ level: ['WARN', 'ERROR'] }), 'the final generation saw no errors').toBe(0); model.verifyDb(ws.dbPath, 'final'); expect(stateDigest(ws.dbPath)).not.toBe('absent'); });