diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 93fced6..3b115f3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -85,6 +85,19 @@ jobs: test-results/ retention-days: 90 + # ── 4b. STRIDE claim/evidence gate ───────────────────────────────────────── + stride-claims: + name: STRIDE Claim/Evidence Gate + runs-on: ubuntu-latest + needs: node-version-check + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Verify every STRIDE ✅ is test-backed or audit-attested + run: npm run check:stride + # ── 5. Secret scan ───────────────────────────────────────────────────────── secret-scan: name: Secret Scan (Gitleaks) diff --git a/docs/STRIDE.md b/docs/STRIDE.md index b59c004..6c17785 100644 --- a/docs/STRIDE.md +++ b/docs/STRIDE.md @@ -26,7 +26,7 @@ | T-1 | Tampering | MEDIUM | ✅ Ph2 | `audit-log.ts` | Log file truncation/replacement undetected at startup | | T-2 | Tampering | HIGH | ⚠️ Ph3 | `model-guard.ts` | Key falls back to `EOS_AGENT_SECRET` (and a hardcoded dev key) when `MODEL_GUARD_SIGN_KEY` is unset — not cryptographically separate unless explicitly configured | | T-3 | Tampering | HIGH | ✅ Ph1 | `agent-auth.ts` | Revocation log has no hash chain; entries can be deleted or corrupted | -| T-4 | Tampering | MEDIUM | ❌ | `decision-ledger.ts` | NOT IMPLEMENTED: only a per-entry `entryHash` exists — no `previousHash`, no `verifyChain`. Entry deletion/reordering is undetectable. Phase-1 "decision ledger hash chain" was aspirational | +| T-4 | Tampering | MEDIUM | ✅ 2026-05-18 | `decision-ledger.ts` | Was aspirational (per-entry hash only). NOW FIXED: every entry carries `previousHash`, chained from `GENESIS`; streaming `verifyChain()` fails closed on content tamper, deletion, or reordering. Legacy pre-chain entries stay verifiable | | T-5 | Tampering | MEDIUM | ✅ 2026-05-18 | `plugin-sandbox.ts` | Was a gap (raw `resolve(msg.result)`). NOW FIXED: every string in a plugin result passes the injection pipeline via bounded recursive `sanitizePluginResult()` | | T-6 | Tampering | LOW | ✅ Ph4 | `sanitize.ts` / `content-filter.ts` | V8 backtracking regex; rewritten with RE2 (linear time) | | R-1 | Repudiation | HIGH | ✅ Ph2 | `ApprovalGateAgent.ts` | `approvedBy` is a free-form string; no cryptographic identity binding | @@ -596,7 +596,7 @@ The following controls are implemented and working. This section provides contex > Summary table above is now authoritative**. Corrections: > > - **E-2** (Phase 2 "HIGH-tier agents in dedicated worker_thread") — ❌ **not implemented.** `IsolatedAgentRunner` exists but is never wired; all agents run in-process. -> - **T-4** (Phase 1 "decision ledger hash chain") — ❌ **not implemented.** Only a per-entry hash; no cross-entry chain. +> - **T-4** (Phase 1 "decision ledger hash chain") — was a gap (per-entry hash only); **now genuinely fixed 2026-05-18** (cross-entry `previousHash` chain + streaming `verifyChain()`). > - **S-3** (Phase 1 "ModelGuard caller gate") — ⚠️ overstated; no caller authentication, only the post-startup lock. > - **S-1** (table-marked Phase 1) — ⚠️ external replay only; never actually in a phase. > - **T-2** (Phase 3 "key separate from EOS_AGENT_SECRET") — ⚠️ falls back to `EOS_AGENT_SECRET` unless explicitly configured. diff --git a/package.json b/package.json index c7b9ffa..a13d27c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "audit:fix": "npm audit fix", "lint": "eslint src/ --ext .ts", "typecheck": "tsc --noEmit", + "check:stride": "node scripts/check-stride-claims.mjs", "sbom": "cyclonedx-npm --output-format json --output-file sbom.json", "compliance": "curl -s http://localhost:3000/api/compliance/status | jq ." }, diff --git a/scripts/check-stride-claims.mjs b/scripts/check-stride-claims.mjs new file mode 100644 index 0000000..d531a28 --- /dev/null +++ b/scripts/check-stride-claims.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * STRIDE claim ↔ evidence gate. + * + * D-6 (and the 2026-05-18 audit) showed a finding can be marked "✅ resolved" + * in docs/STRIDE.md while the code does nothing. This gate makes that class + * of drift fail CI: + * + * - Every finding in the STRIDE summary table MUST have a manifest entry + * here (a new finding cannot be added without a conscious decision). + * - A finding the table marks ✅ MUST be backed by either: + * • `test`: an attack-path regression test that exists AND references + * the finding id, OR + * • `attested`: an explicit code anchor (verified by audit, no + * dedicated automated test yet) — surfaced as debt, not silent. + * - A finding the table does NOT mark ✅ (⚠️/❌/🔬) MUST be declared + * `resolved:false` here, so flipping it to ✅ requires editing both the + * doc and this manifest — they cannot silently diverge. + * + * Run: node scripts/check-stride-claims.mjs (npm run check:stride) + */ +import { readFileSync, existsSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const stridePath = resolve(root, 'docs/STRIDE.md'); + +// ── Evidence manifest ──────────────────────────────────────────────────────── +// test → attack-path regression test (must exist and mention the id) +// attested → code anchor verified by the 2026-05-18 audit (tracked debt) +// resolved:false → the finding is NOT claimed resolved (⚠️/❌/🔬) +const MANIFEST = { + 'S-1': { resolved: false }, // ⚠️ external-replay only; in-process theft open + 'S-2': { attested: 'ApprovalGateAgent.ts submitDecision() — no approval:decision EventBus intake' }, + 'S-3': { resolved: false }, // ⚠️ no caller auth; only post-startup lock + 'S-4': { attested: 'PolicyEngine.lock(); SupervisorAgent.start() calls it' }, + 'T-1': { attested: 'audit-log.ts initialize() → verifyChain() + alert' }, + 'T-2': { resolved: false }, // ⚠️ falls back to EOS_AGENT_SECRET + 'T-3': { attested: 'agent-auth.ts persistRevocation()/loadPersistentRevocations() hash chain, fail-closed' }, + 'T-4': { test: 'tests/security/decision-ledger-chain.test.ts' }, + 'T-5': { test: 'tests/security/plugin-return-sanitization.test.ts' }, + 'T-6': { attested: 'safe-regex.ts re2Pattern used across sanitize.ts/content-filter.ts' }, + 'R-1': { attested: 'ApprovalGateAgent.ts verifyApprovalToken — challengeNonce + approvedBy in HMAC' }, + 'R-2': { attested: 'shutdown.ts uncaughtException/unhandledRejection → emergencyFlush' }, + 'R-3': { attested: 'decision-ledger.ts createWriteStream (no appendFileSync on write path)' }, + 'I-1': { attested: 'agent-auth.ts lockIssuance(); issueToken() throws when locked' }, + 'I-2': { attested: 'secrets-provider.ts lockSecretsProvider(); setSecretsProvider() throws when locked' }, + 'I-3': { attested: 'audit-log.ts scrubMetadata() → scrubPII() on the write path' }, + 'I-4': { attested: 'plugin-sandbox.ts validateConfig() CREDENTIAL_*_RE invoked in constructor' }, + 'I-5': { resolved: false }, // ⚠️ + 'D-1': { attested: 'agent-auth.ts setInterval(5m).unref() purges expired tokens + nonces' }, + 'D-2': { attested: 'EventBus.ts checkGlobalRateLimit() — 10k/60s global ceiling in emit()' }, + 'D-3': { attested: 'sanitize.ts setInterval(5m).unref() purges stale rate-limit counters' }, + 'D-4': { attested: 'decision-ledger.ts queryDisk() + audit-log verifyChain() readline streaming' }, + 'D-5': { resolved: false }, // 🔬 formal proof pending + 'D-6': { test: 'tests/security/glasswally-line-buffer.test.ts' }, + 'E-1': { attested: 'ApprovalGateAgent.ts authenticated submitDecision() + per-approval challenge nonce' }, + 'E-2': { resolved: false }, // ❌ IsolatedAgentRunner exists but unwired + 'E-3': { attested: 'SupervisorAgent.start() → policyEngine.lock(); addPolicy() throws when locked' }, + 'E-4': { attested: 'shutdown.ts finalizeStartup() → ModelGuard.lockModels(); approve() throws when locked' }, + 'E-5': { test: 'tests/security/agent-registry-collision.test.ts' }, +}; + +// ── Parse the STRIDE finding summary table ─────────────────────────────────── +const md = readFileSync(stridePath, 'utf8'); +const table = new Map(); // id -> { resolved: boolean, status: string } +for (const line of md.split('\n')) { + const m = /^\|\s*([STRIDE]-\d+|[A-Z]-\d+)\s*\|/.exec(line); + if (!m) continue; + const cells = line.split('|').map((c) => c.trim()); + const id = cells[1]; + const status = cells[4] ?? ''; + table.set(id, { resolved: status.includes('✅'), status }); +} + +if (table.size === 0) { + console.error('FAIL: could not parse any findings from docs/STRIDE.md table'); + process.exit(1); +} + +// ── Enforce ────────────────────────────────────────────────────────────────── +const errors = []; +const tested = []; +const attested = []; +const notResolved = []; + +for (const [id, { resolved, status }] of table) { + const entry = MANIFEST[id]; + if (!entry) { + errors.push(`${id}: in STRIDE table (status "${status}") but missing from the evidence manifest. Add an entry to scripts/check-stride-claims.mjs.`); + continue; + } + + if (resolved) { + if (entry.resolved === false) { + errors.push(`${id}: STRIDE marks it ✅ but the manifest declares it unresolved. Reconcile the doc and the manifest.`); + continue; + } + if (entry.test) { + const p = resolve(root, entry.test); + if (!existsSync(p)) { + errors.push(`${id}: linked test "${entry.test}" does not exist.`); + } else if (!readFileSync(p, 'utf8').includes(id)) { + errors.push(`${id}: linked test "${entry.test}" does not reference "${id}" — link unverifiable.`); + } else { + tested.push(id); + } + } else if (entry.attested && String(entry.attested).trim()) { + attested.push(id); + } else { + errors.push(`${id}: STRIDE marks it ✅ but the manifest provides neither a "test" nor an "attested" code anchor.`); + } + } else { + if (entry.resolved !== false) { + errors.push(`${id}: STRIDE status is "${status}" (not ✅) but the manifest does not declare resolved:false. A non-resolved finding must not carry resolution evidence.`); + } else { + notResolved.push(id); + } + } +} + +for (const id of Object.keys(MANIFEST)) { + if (!table.has(id)) { + errors.push(`${id}: present in the manifest but not in the STRIDE table — stale manifest entry.`); + } +} + +// ── Report ─────────────────────────────────────────────────────────────────── +console.log(`STRIDE findings: ${table.size}`); +console.log(` ✅ test-backed (${tested.length}): ${tested.join(', ') || '—'}`); +console.log(` ✅ attested-only (${attested.length}): ${attested.join(', ') || '—'}`); +console.log(` ▫ not resolved (${notResolved.length}): ${notResolved.join(', ') || '—'}`); +if (attested.length) { + console.log(`\nDEBT: ${attested.length} resolved findings are audit-attested but have no automated attack-path test. Convert these to "test" over time.`); +} + +if (errors.length) { + console.error(`\nFAIL — STRIDE claim/evidence gate (${errors.length}):`); + for (const e of errors) console.error(` ✗ ${e}`); + process.exit(1); +} +console.log('\nPASS — every STRIDE ✅ is test-backed or audit-attested; doc and manifest agree.'); diff --git a/src/security/decision-ledger.ts b/src/security/decision-ledger.ts index c53fd2c..a441a40 100644 --- a/src/security/decision-ledger.ts +++ b/src/security/decision-ledger.ts @@ -139,6 +139,13 @@ export interface LedgerEntry extends DecisionLedgerInput { ledgerId: string; recordedAt: string; recordedAtMs: number; + /** + * Hash of the previous ledger entry, forming a tamper-evident chain + * (STRIDE T-4). 'GENESIS' for the first entry. Deleting or reordering any + * entry breaks the next entry's link. Absent on legacy entries written + * before the chain existed — verifyChain() tolerates those. + */ + previousHash: string; entryHash: string; } @@ -150,6 +157,14 @@ export interface LedgerVerificationResult { storedHash?: string; } +export interface LedgerChainResult { + valid: boolean; + totalEntries: number; + /** 0-based index of the entry where the chain broke, if any */ + brokenAt?: number; + reason?: string; +} + // ───────────────────────────────────────────────────────────────────────────── // Storage // ───────────────────────────────────────────────────────────────────────────── @@ -161,6 +176,10 @@ const LEDGER_FILE_PATH = resolve( const INDEX_LIMIT = 50_000; const ledgerIndex = new Map(); +// Head of the tamper-evident chain (STRIDE T-4). Bootstrapped from the last +// entry on disk in initialize() so appends keep chaining across restarts. +let lastEntryHash = 'GENESIS'; + // ───────────────────────────────────────────────────────────────────────────── // Async write stream — non-blocking disk I/O (mirrors audit-log.ts pattern) // ───────────────────────────────────────────────────────────────────────────── @@ -272,9 +291,11 @@ export const DecisionLedger = { ledgerId, recordedAt: now.toISOString(), recordedAtMs: now.getTime(), + previousHash: lastEntryHash, }; const entry: LedgerEntry = { ...partial, entryHash: computeEntryHash(partial) }; + lastEntryHash = entry.entryHash; // Index in memory — evict oldest if over limit if (ledgerIndex.size >= INDEX_LIMIT) { @@ -366,6 +387,76 @@ export const DecisionLedger = { return { valid: true, ledgerId }; }, + /** + * Verify the whole-ledger tamper-evident chain (STRIDE T-4). Streams the + * file (no OOM, STRIDE D-4) and fails closed on the first broken link: + * - a recomputed entryHash that doesn't match (content tampered), or + * - a previousHash that doesn't match the prior entry's hash (an entry + * was deleted, reordered, or inserted). + * Legacy entries written before the chain existed have no `previousHash`; + * their per-entry hash is still checked but the link check is skipped, and + * the chain is enforced strictly from the first chained entry onward. + */ + async verifyChain(): Promise { + if (!existsSync(LEDGER_FILE_PATH)) return { valid: true, totalEntries: 0 }; + + let running = 'GENESIS'; + let totalEntries = 0; + + const rl = createInterface({ + input: createReadStream(LEDGER_FILE_PATH, { encoding: 'utf8' }), + crlfDelay: Infinity, + }); + + for await (const rawLine of rl) { + const line = rawLine.trim(); + if (!line) continue; + + let entry: LedgerEntry; + try { + entry = JSON.parse(line) as LedgerEntry; + } catch { + rl.close(); + return { + valid: false, + totalEntries, + brokenAt: totalEntries, + reason: `JSON parse error at entry ${totalEntries + 1}`, + }; + } + + const { entryHash, ...rest } = entry; + if (computeEntryHash(rest) !== entryHash) { + rl.close(); + return { + valid: false, + totalEntries, + brokenAt: totalEntries, + reason: `Entry hash mismatch at entry ${totalEntries + 1} — content modified after recording`, + }; + } + + // Link check only for chained entries. Legacy entries (no previousHash) + // are content-verified above; the chain resumes from their hash. + if (typeof entry.previousHash === 'string') { + if (entry.previousHash !== running) { + rl.close(); + return { + valid: false, + totalEntries, + brokenAt: totalEntries, + reason: `Chain broken at entry ${totalEntries + 1} — previousHash does not match (entry deleted, reordered, or inserted)`, + }; + } + } + + running = entryHash; + totalEntries++; + } + + return { valid: true, totalEntries }; + }, + /** * Query the in-memory index. For full historical queries use queryDisk(). */ @@ -489,6 +580,25 @@ export const DecisionLedger = { } if (!existsSync(LEDGER_FILE_PATH)) { writeFileSync(LEDGER_FILE_PATH, '', { encoding: 'utf8' }); + lastEntryHash = 'GENESIS'; + return; + } + // Bootstrap the chain head from the last entry on disk so appends keep + // chaining across restarts (STRIDE T-4). + const lines = readFileSync(LEDGER_FILE_PATH, 'utf8') + .split('\n') + .filter((l) => l.trim().length > 0); + lastEntryHash = 'GENESIS'; + for (let i = lines.length - 1; i >= 0; i--) { + try { + const last = JSON.parse(lines[i]) as LedgerEntry; + if (typeof last.entryHash === 'string' && last.entryHash) { + lastEntryHash = last.entryHash; + break; + } + } catch { + // Skip trailing malformed lines; keep scanning backward. + } } }, }; diff --git a/tests/security/decision-ledger-chain.test.ts b/tests/security/decision-ledger-chain.test.ts new file mode 100644 index 0000000..e656433 --- /dev/null +++ b/tests/security/decision-ledger-chain.test.ts @@ -0,0 +1,136 @@ +/** + * Decision Ledger — Tamper-Evident Hash Chain (STRIDE T-4) + * + * NIST AI RMF 1.0 — MEASURE (MS-2.6) — decision provenance / non-repudiation + * + * Run: npx jest tests/security/decision-ledger-chain.test.ts --verbose + * + * STRIDE T-4 ("decision ledger hash chain") was marked resolved but the + * ledger only had per-entry hashes — deletion/reordering was undetectable. + * This proves the implemented cross-entry chain: a clean ledger verifies, + * content tampering and entry deletion are both caught with the breaking + * index, and pre-chain (legacy) entries remain verifiable (back-compat). + */ + +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createHash } from 'crypto'; + +let dir: string; +let path: string; +let DL: typeof import('../../src/security/decision-ledger').DecisionLedger; +let flush: typeof import('../../src/security/decision-ledger').flushDecisionLedger; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'dl-chain-')); + path = join(dir, 'decisions.jsonl'); + process.env.DECISION_LEDGER_PATH = path; + const mod = await import('../../src/security/decision-ledger'); + DL = mod.DecisionLedger; + flush = mod.flushDecisionLedger; +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +beforeEach(async () => { + await flush(); // close any open append stream from the prior test + writeFileSync(path, '', 'utf8'); + DL.initialize(); // resets chain head to GENESIS for the empty file +}); + +function record(n: number) { + return DL.record({ + agentId: `agent-${n}`, + decisionType: 'llm.completion', + context: DL.buildContext({ modelId: 'm', promptTemplate: `p${n}` }), + inputHash: `in${n}`, + outputHash: `out${n}`, + }); +} + +const lines = () => readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()); + +test('a clean ledger verifies and entries are linked', async () => { + const e1 = record(1); + const e2 = record(2); + const e3 = record(3); + await flush(); + + expect(e1.previousHash).toBe('GENESIS'); + expect(e2.previousHash).toBe(e1.entryHash); + expect(e3.previousHash).toBe(e2.entryHash); + + const res = await DL.verifyChain(); + expect(res).toEqual({ valid: true, totalEntries: 3 }); +}); + +test('content tampering is detected at the modified entry', async () => { + record(1); record(2); record(3); + await flush(); + + const ls = lines(); + const middle = JSON.parse(ls[1]); + middle.outcome = { tampered: true }; // change content, keep its entryHash + ls[1] = JSON.stringify(middle); + writeFileSync(path, ls.join('\n') + '\n', 'utf8'); + + const res = await DL.verifyChain(); + expect(res.valid).toBe(false); + expect(res.brokenAt).toBe(1); + expect(res.reason).toMatch(/hash mismatch/i); +}); + +test('deleting an entry breaks the chain at the next entry', async () => { + record(1); record(2); record(3); + await flush(); + + const ls = lines(); + ls.splice(1, 1); // delete the middle entry + writeFileSync(path, ls.join('\n') + '\n', 'utf8'); + + const res = await DL.verifyChain(); + expect(res.valid).toBe(false); + expect(res.brokenAt).toBe(1); + expect(res.reason).toMatch(/chain broken|deleted|reordered/i); +}); + +test('reordering entries is detected', async () => { + record(1); record(2); record(3); + await flush(); + const ls = lines(); + [ls[0], ls[1]] = [ls[1], ls[0]]; // swap first two + writeFileSync(path, ls.join('\n') + '\n', 'utf8'); + + const res = await DL.verifyChain(); + expect(res.valid).toBe(false); +}); + +test('legacy entries without previousHash remain verifiable (back-compat)', async () => { + const sha = (v: string) => createHash('sha256').update(v).digest('hex'); + + // Two pre-chain entries: no `previousHash` field at all. + const legacy = [1, 2].map((n) => { + const base = { + agentId: `legacy-${n}`, + decisionType: 'llm.completion', + context: DL.buildContext({ modelId: 'm', promptTemplate: `lp${n}` }), + inputHash: `lin${n}`, + outputHash: `lout${n}`, + ledgerId: `legacy-id-${n}`, + recordedAt: new Date().toISOString(), + recordedAtMs: Date.now(), + }; + return { ...base, entryHash: sha(JSON.stringify(base)) }; + }); + writeFileSync(path, legacy.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8'); + + // initialize() bootstraps the chain head from the last (legacy) entry, + // so a new chained entry links onto it. + DL.initialize(); + record(99); + await flush(); + + const res = await DL.verifyChain(); + expect(res).toEqual({ valid: true, totalEntries: 3 }); +});