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';
}
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<EvidenceVaultEvents> {
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<EvidenceVaultEvents> {
updateFinding(findingId: string, updates: Partial<Finding>): Finding | undefined {
const finding = this.findings.get(findingId);
if (finding) {
Object.assign(finding, updates);
this.emit('finding:updated', finding);
const sanitized: Partial<Finding> = { ...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<EvidenceVaultEvents> {
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<EvidenceVaultEvents> {
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<EvidenceVaultEvents> {
* 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<EvidenceVaultEvents> {
validatedCredentials: number;
riskScore: number;
} {
const findings = this.getAllFindings();
const findings = Array.from(this.findings.values());
const credentials = this.getAllCredentials();
const bySeverity: Record<Severity, number> = {
@@ -254,7 +290,7 @@ export class EvidenceVault extends EventEmitter<EvidenceVaultEvents> {
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,
+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' },
postgres_uri: { pattern: /postgres(ql)?:\/\/[^:]+:[^@]+@[^/]+\/\w+/gi, severity: 'critical', provider: 'PostgreSQL' },
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' },
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' },