Merge pull request #12 from mane/fix/evidence-redaction-integrity

fix: harden evidence verification state
This commit is contained in:
pliny authored and GitHub committed 2026-07-05 13:55:39 -04:00
commit edab7a8e5e
4 files changed
+151 -17

No files matched your search

@@ -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> = {}): 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);
});
});
+33
View File
@@ -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');
});
});
+53 -17
View File
@@ -65,6 +65,30 @@ export function cvssToSeverity(cvss: number): Severity {
return 'info'; 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 // EVIDENCE VAULT
// ============================================================================= // =============================================================================
@@ -80,9 +104,10 @@ export class EvidenceVault extends EventEmitter<EvidenceVaultEvents> {
if (!finding.id) { if (!finding.id) {
finding.id = randomUUID(); finding.id = randomUUID();
} }
this.findings.set(finding.id, finding); const stored = cloneFinding(finding);
this.emit('finding:added', finding); this.findings.set(stored.id, stored);
return finding; this.emit('finding:added', cloneFinding(stored));
return cloneFinding(stored);
} }
/** /**
@@ -91,10 +116,19 @@ export class EvidenceVault extends EventEmitter<EvidenceVaultEvents> {
updateFinding(findingId: string, updates: Partial<Finding>): Finding | undefined { updateFinding(findingId: string, updates: Partial<Finding>): Finding | undefined {
const finding = this.findings.get(findingId); const finding = this.findings.get(findingId);
if (finding) { if (finding) {
Object.assign(finding, updates); const sanitized: Partial<Finding> = { ...updates };
this.emit('finding:updated', finding); // 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<EvidenceVaultEvents> {
finding.verifyGate = { passed: gate.passed, provenance: gate.provenance, reasons: gate.reasons, checkedAt: gate.checkedAt }; finding.verifyGate = { passed: gate.passed, provenance: gate.provenance, reasons: gate.reasons, checkedAt: gate.checkedAt };
if (gate.passed) { if (gate.passed) {
finding.verifiedAt = Date.now(); finding.verifiedAt = Date.now();
this.emit('finding:verified', finding); this.emit('finding:verified', cloneFinding(finding));
} else { } else {
// refuse to stamp verified on a finding the gate could not back // refuse to stamp verified on a finding the gate could not back
delete finding.verifiedAt; 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<EvidenceVaultEvents> {
addEvidence(findingId: string, evidence: Evidence): Finding | undefined { addEvidence(findingId: string, evidence: Evidence): Finding | undefined {
const finding = this.findings.get(findingId); const finding = this.findings.get(findingId);
if (finding) { if (finding) {
finding.evidence.push(evidence); const storedEvidence = cloneEvidence(evidence);
this.emit('evidence:added', { findingId, 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 * Get a finding by ID
*/ */
getFinding(findingId: string): Finding | undefined { getFinding(findingId: string): Finding | undefined {
return this.findings.get(findingId); const finding = this.findings.get(findingId);
return finding ? cloneFinding(finding) : undefined;
} }
/** /**
* Get all findings * Get all findings
*/ */
getAllFindings(): Finding[] { 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<EvidenceVaultEvents> {
* Get verified findings * Get verified findings
*/ */
getVerifiedFindings(): Finding[] { 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<EvidenceVaultEvents> {
validatedCredentials: number; validatedCredentials: number;
riskScore: number; riskScore: number;
} { } {
const findings = this.getAllFindings(); const findings = Array.from(this.findings.values());
const credentials = this.getAllCredentials(); const credentials = this.getAllCredentials();
const bySeverity: Record<Severity, number> = { const bySeverity: Record<Severity, number> = {
@@ -254,7 +290,7 @@ export class EvidenceVault extends EventEmitter<EvidenceVaultEvents> {
return { return {
totalFindings: findings.length, totalFindings: findings.length,
verifiedFindings: findings.filter(f => f.verifiedAt !== undefined).length, verifiedFindings: findings.filter(hasPassedVerificationGate).length,
bySeverity, bySeverity,
totalCredentials: credentials.length, totalCredentials: credentials.length,
validatedCredentials: credentials.filter(c => c.validatedAt !== undefined).length, validatedCredentials: credentials.filter(c => c.validatedAt !== undefined).length,
+1
View File
@@ -20,6 +20,7 @@ export const SECRET_PATTERNS: Record<string, { pattern: RegExp; severity: string
slack_token: { pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g, severity: 'high', provider: 'Slack' }, slack_token: { pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g, severity: 'high', provider: 'Slack' },
postgres_uri: { pattern: /postgres(ql)?:\/\/[^:]+:[^@]+@[^/]+\/\w+/gi, severity: 'critical', provider: 'PostgreSQL' }, postgres_uri: { pattern: /postgres(ql)?:\/\/[^:]+:[^@]+@[^/]+\/\w+/gi, severity: 'critical', provider: 'PostgreSQL' },
mongodb_uri: { pattern: /mongodb(\+srv)?:\/\/[^:]+:[^@]+@[^/]+/gi, severity: 'critical', provider: 'MongoDB' }, mongodb_uri: { pattern: /mongodb(\+srv)?:\/\/[^:]+:[^@]+@[^/]+/gi, severity: 'critical', provider: 'MongoDB' },
pem_private_key: { pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, severity: 'critical', provider: 'PEM' },
rsa_private: { pattern: new RegExp('-----BEGIN RSA ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'RSA' }, rsa_private: { pattern: new RegExp('-----BEGIN RSA ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'RSA' },
openssh_private: { pattern: new RegExp('-----BEGIN OPENSSH ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'OpenSSH' }, openssh_private: { pattern: new RegExp('-----BEGIN OPENSSH ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'OpenSSH' },
password_field: { pattern: /password["\s]*[:=]["\s]*[^"'\s]{4,}/gi, severity: 'high', provider: 'Generic' }, password_field: { pattern: /password["\s]*[:=]["\s]*[^"'\s]{4,}/gi, severity: 'high', provider: 'Generic' },