diff --git a/src/__tests__/evidence-vault-integrity.test.ts b/src/__tests__/evidence-vault-integrity.test.ts new file mode 100644 index 0000000..808d32e --- /dev/null +++ b/src/__tests__/evidence-vault-integrity.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { EvidenceVault } from '../evidence/index.js'; +import { KillChainPhase, type Finding } from '../types/index.js'; + +function finding(overrides: Partial = {}): Finding { + return { + id: 'finding-1', + title: 'unverified claim', + description: 'model-only claim', + severity: 'high', + targetId: 'target-1', + operatorId: 'operator-1', + phase: KillChainPhase.EXPLOIT, + evidence: [], + discoveredAt: 1, + ...overrides, + }; +} + +const forgedGate = { passed: true, provenance: 'tool' as const, reasons: [], checkedAt: 2 }; + +describe('EvidenceVault verification integrity', () => { + it('updateFinding cannot forge verifiedAt or verifyGate', () => { + const vault = new EvidenceVault(); + vault.addFinding(finding()); + + vault.updateFinding('finding-1', { verifiedAt: 123, verifyGate: forgedGate }); + + const stored = vault.getFinding('finding-1'); + expect(stored?.verifiedAt).toBeUndefined(); + expect(stored?.verifyGate).toBeUndefined(); + expect(vault.getVerifiedFindings()).toEqual([]); + }); + + it('getFinding returns a snapshot, not a mutable handle into the vault', () => { + const vault = new EvidenceVault(); + vault.addFinding(finding()); + + const snapshot = vault.getFinding('finding-1'); + expect(snapshot).toBeDefined(); + if (!snapshot) throw new Error('expected snapshot'); + snapshot.verifiedAt = 123; + snapshot.verifyGate = forgedGate; + snapshot.evidence.push({ type: 'output', content: 'forged after read', timestamp: 3 }); + + const stored = vault.getFinding('finding-1'); + expect(stored?.verifiedAt).toBeUndefined(); + expect(stored?.verifyGate).toBeUndefined(); + expect(stored?.evidence).toEqual([]); + expect(vault.getVerifiedFindings()).toEqual([]); + }); + + it('getVerifiedFindings requires the gate to have passed, not just a timestamp', () => { + const vault = new EvidenceVault(); + vault.addFinding(finding({ + id: 'blocked', + verifiedAt: 123, + verifyGate: { passed: false, provenance: 'none', reasons: ['blocked'], checkedAt: 2 }, + })); + + expect(vault.getVerifiedFindings()).toEqual([]); + expect(vault.getStats().verifiedFindings).toBe(0); + }); +}); diff --git a/src/__tests__/redact-pem.test.ts b/src/__tests__/redact-pem.test.ts new file mode 100644 index 0000000..cd2820f --- /dev/null +++ b/src/__tests__/redact-pem.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { redactString } from '../redact.js'; + +describe('redactString — full private-key block redaction', () => { + it('redacts an entire PEM private-key block, not just the BEGIN header', () => { + const pem = [ + 'before', + '-----BEGIN RSA PRIVATE KEY-----', + 'MIIEpAIBAAKCAQEA7thisLineMustDisappear', + 'anotherSecretKeyLineMustDisappear', + '-----END RSA PRIVATE KEY-----', + 'after', + ].join('\n'); + + const out = redactString(pem); + + expect(out).toContain('before'); + expect(out).toContain('after'); + expect(out).toContain('[redacted]'); + expect(out).not.toContain('BEGIN RSA PRIVATE KEY'); + expect(out).not.toContain('END RSA PRIVATE KEY'); + expect(out).not.toContain('thisLineMustDisappear'); + expect(out).not.toContain('anotherSecretKeyLineMustDisappear'); + }); + + it('redacts OpenSSH private-key blocks as a whole', () => { + const pem = 'x\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAAsecret\n-----END OPENSSH PRIVATE KEY-----\ny'; + + const out = redactString(pem); + + expect(out).toBe('x\n[redacted]\ny'); + }); +}); diff --git a/src/evidence/index.ts b/src/evidence/index.ts index 2eb1535..f1a647f 100755 --- a/src/evidence/index.ts +++ b/src/evidence/index.ts @@ -65,6 +65,30 @@ export function cvssToSeverity(cvss: number): Severity { return 'info'; } +function cloneEvidence(evidence: Evidence): Evidence { + return { + ...evidence, + metadata: evidence.metadata ? { ...evidence.metadata } : undefined, + }; +} + +function cloneFinding(finding: Finding): Finding { + return { + ...finding, + cve: finding.cve ? [...finding.cve] : undefined, + cwe: finding.cwe ? [...finding.cwe] : undefined, + references: finding.references ? [...finding.references] : undefined, + evidence: Array.isArray(finding.evidence) ? finding.evidence.map(cloneEvidence) : [], + verifyGate: finding.verifyGate + ? { ...finding.verifyGate, reasons: [...finding.verifyGate.reasons] } + : undefined, + }; +} + +function hasPassedVerificationGate(finding: Finding): boolean { + return finding.verifiedAt !== undefined && finding.verifyGate?.passed === true; +} + // ============================================================================= // EVIDENCE VAULT // ============================================================================= @@ -80,9 +104,10 @@ export class EvidenceVault extends EventEmitter { if (!finding.id) { finding.id = randomUUID(); } - this.findings.set(finding.id, finding); - this.emit('finding:added', finding); - return finding; + const stored = cloneFinding(finding); + this.findings.set(stored.id, stored); + this.emit('finding:added', cloneFinding(stored)); + return cloneFinding(stored); } /** @@ -91,10 +116,19 @@ export class EvidenceVault extends EventEmitter { updateFinding(findingId: string, updates: Partial): Finding | undefined { const finding = this.findings.get(findingId); if (finding) { - Object.assign(finding, updates); - this.emit('finding:updated', finding); + const sanitized: Partial = { ...updates }; + // Verification state is gate-owned. Callers may update finding metadata, + // evidence, remediation, etc., but cannot self-stamp a finding as verified. + delete sanitized.verifiedAt; + delete sanitized.verifyGate; + if (sanitized.evidence) sanitized.evidence = sanitized.evidence.map(cloneEvidence); + if (sanitized.cve) sanitized.cve = [...sanitized.cve]; + if (sanitized.cwe) sanitized.cwe = [...sanitized.cwe]; + if (sanitized.references) sanitized.references = [...sanitized.references]; + Object.assign(finding, sanitized); + this.emit('finding:updated', cloneFinding(finding)); } - return finding; + return finding ? cloneFinding(finding) : undefined; } /** @@ -109,13 +143,13 @@ export class EvidenceVault extends EventEmitter { finding.verifyGate = { passed: gate.passed, provenance: gate.provenance, reasons: gate.reasons, checkedAt: gate.checkedAt }; if (gate.passed) { finding.verifiedAt = Date.now(); - this.emit('finding:verified', finding); + this.emit('finding:verified', cloneFinding(finding)); } else { // refuse to stamp verified on a finding the gate could not back delete finding.verifiedAt; - this.emit('finding:gate-blocked', finding); + this.emit('finding:gate-blocked', cloneFinding(finding)); } - return finding; + return cloneFinding(finding); } /** @@ -124,24 +158,26 @@ export class EvidenceVault extends EventEmitter { addEvidence(findingId: string, evidence: Evidence): Finding | undefined { const finding = this.findings.get(findingId); if (finding) { - finding.evidence.push(evidence); - this.emit('evidence:added', { findingId, evidence }); + const storedEvidence = cloneEvidence(evidence); + finding.evidence.push(storedEvidence); + this.emit('evidence:added', { findingId, evidence: cloneEvidence(storedEvidence) }); } - return finding; + return finding ? cloneFinding(finding) : undefined; } /** * Get a finding by ID */ getFinding(findingId: string): Finding | undefined { - return this.findings.get(findingId); + const finding = this.findings.get(findingId); + return finding ? cloneFinding(finding) : undefined; } /** * Get all findings */ getAllFindings(): Finding[] { - return Array.from(this.findings.values()); + return Array.from(this.findings.values()).map(cloneFinding); } /** @@ -169,7 +205,7 @@ export class EvidenceVault extends EventEmitter { * Get verified findings */ getVerifiedFindings(): Finding[] { - return this.getAllFindings().filter(f => f.verifiedAt !== undefined); + return Array.from(this.findings.values()).filter(hasPassedVerificationGate).map(cloneFinding); } /** @@ -235,7 +271,7 @@ export class EvidenceVault extends EventEmitter { validatedCredentials: number; riskScore: number; } { - const findings = this.getAllFindings(); + const findings = Array.from(this.findings.values()); const credentials = this.getAllCredentials(); const bySeverity: Record = { @@ -254,7 +290,7 @@ export class EvidenceVault extends EventEmitter { return { totalFindings: findings.length, - verifiedFindings: findings.filter(f => f.verifiedAt !== undefined).length, + verifiedFindings: findings.filter(hasPassedVerificationGate).length, bySeverity, totalCredentials: credentials.length, validatedCredentials: credentials.filter(c => c.validatedAt !== undefined).length, diff --git a/src/redact.ts b/src/redact.ts index 7264118..327f4e3 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -20,6 +20,7 @@ export const SECRET_PATTERNS: Record