Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,21 @@
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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Secret Scan (Gitleaks)
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 2 additions & 2 deletions docs/STRIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
},
Expand Down
143 changes: 143 additions & 0 deletions scripts/check-stride-claims.mjs
Original file line number Diff line number Diff line change
@@ -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.');
110 changes: 110 additions & 0 deletions src/security/decision-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -161,6 +176,10 @@ const LEDGER_FILE_PATH = resolve(
const INDEX_LIMIT = 50_000;
const ledgerIndex = new Map<string, LedgerEntry>();

// 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)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<LedgerChainResult> {
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().
*/
Expand Down Expand Up @@ -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.
}
}
},
};
Expand Down
Loading
Loading