fix: harden evidence verification state
This commit is contained in:
1 parent
bd2463e0c3
commit
380d540e50
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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user