Merge pull request #12 from mane/fix/evidence-redaction-integrity
fix: harden evidence verification state
This commit is contained in:
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';
|
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,
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in new issue
Block a user