139 lines
9.1 KiB
TypeScript
139 lines
9.1 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { RegulatoryMappingService } from '../../server/regulatory/regulatory-mapping-service';
|
|
import { RegulatoryFindingRetrievalService } from '../../server/regulatory/regulatory-finding-retrieval';
|
|
import type { RegulatoryMappingDecision, RegulatoryMappingRun } from '../../src/domain/regulatory-mapping';
|
|
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/domain/regulatory-rag';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
|
|
|
|
const stamp = '2026-09-15T10:00:00.000Z';
|
|
// Result-table / occurrence data that must never reach the model or the query.
|
|
const resultTableAsset = 'DC01.customer.local';
|
|
|
|
function finding(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResultProjection {
|
|
return {
|
|
resultProjectionId: 'scanner-finding:occurrence-1', sourceKind: 'scanner-finding', sourceResultId: 'occurrence-1',
|
|
projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'run-1', scanRunId: 'run-1',
|
|
sourceTool: 'purple-knight', sourceRuleId: 'A-SmbSigning', title: 'SMB signing is not required on domain controllers',
|
|
description: 'The SMB server permits unsigned sessions, allowing relay attacks.', affectedAsset: resultTableAsset,
|
|
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
|
technicalExecutionState: 'completed', resultType: 'compliance_check', isIoe: false, isDeviation: true,
|
|
lifecycleState: 'new', mappingConfidence: 1, reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, frameworkReferences: [], categories: [],
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002'): RegulatoryChunk {
|
|
const reference = sourceStandard === 'ISO/IEC 27001';
|
|
return {
|
|
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? '27001-5.1' : '27002-5.1-purpose',
|
|
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId: '5.1', annexAControlId: 'A.5.1',
|
|
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
|
|
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
|
|
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
|
|
chunkId: reference ? 'reference-5.1' : 'semantic-5.1', chunkIndex: 0, language: 'de', title: 'Synthetic control',
|
|
text: reference ? 'Synthetic normative statement.' : 'Synthetic semantic evidence.', textSha256: 'a'.repeat(64)
|
|
};
|
|
}
|
|
|
|
/** In-memory repository keyed like the global (rule-keyed) schema. */
|
|
function mockRepository(findings: UnifiedResultProjection[]) {
|
|
const byProjection = new Map(findings.map(item => [item.resultProjectionId, item]));
|
|
const runs = new Map<string, RegulatoryMappingRun>();
|
|
const decisions: RegulatoryMappingDecision[] = [];
|
|
return {
|
|
getUnifiedResultById: vi.fn(async (id: string) => byProjection.get(id) ?? null),
|
|
getRegulatoryMappingRun: vi.fn(async (id: string) => runs.get(id) ?? null),
|
|
saveRegulatoryMappingRun: vi.fn(async (run: RegulatoryMappingRun) => { runs.set(run.id, run); return run; }),
|
|
listRegulatoryMappingRuns: vi.fn(async () => [...runs.values()]),
|
|
createRegulatoryMappingDecision: vi.fn(async (decision: RegulatoryMappingDecision, expected?: string) => {
|
|
const run = runs.get(decision.mappingId);
|
|
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') throw new Error('missing mapping');
|
|
if (!run.candidates.some(c => c.controlId === decision.controlId)) throw new Error('ungrounded control');
|
|
const latest = decisions.filter(d => d.ruleKey === decision.ruleKey && d.controlId === decision.controlId)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))[0];
|
|
if ((expected ?? undefined) !== (latest?.id ?? undefined)) throw new Error('conflict');
|
|
const stored = { ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) };
|
|
decisions.push(stored);
|
|
return stored;
|
|
}),
|
|
listRegulatoryMappingDecisions: vi.fn(async (ruleKey: string) => decisions.filter(d => d.ruleKey === ruleKey)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)))
|
|
} as unknown as AssessorRepository;
|
|
}
|
|
|
|
function service(repository: AssessorRepository, complete: (system: string, input: string) => Promise<unknown>) {
|
|
return new RegulatoryMappingService(repository, {
|
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002') }]) }),
|
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
|
|
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
|
|
now: () => new Date(stamp)
|
|
});
|
|
}
|
|
|
|
describe('regulatory mapping service', () => {
|
|
it('shares one global mapping and decision across projects and sends no result-table data', async () => {
|
|
const findingB = finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2', projectId: 'project-2', affectedAsset: 'DC02.other.local' });
|
|
const repository = mockRepository([finding(), findingB]);
|
|
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
|
|
}, usage: { totalTokens: 12 } }));
|
|
const svc = service(repository, complete);
|
|
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
|
|
// The same rule mapped once in project-1 is visible to project-2 (global sharing).
|
|
const shared = await svc.get('project-2', 'scanner-finding:occurrence-2');
|
|
expect(shared).toMatchObject({ state: 'succeeded', run: { ruleKey: 'purple-knight:A-SmbSigning', sourceTool: 'purple-knight', embeddingModel: 'bge-m3', tokenUsage: { totalTokens: 12 } } });
|
|
expect(shared.run?.result?.mappings[0].controlId).toBe('5.1');
|
|
expect(complete).toHaveBeenCalledOnce();
|
|
|
|
// The copyrighted ISO clause text is stripped from the response but still reaches the model.
|
|
expect(shared.run?.candidates.every(c => c.evidence.every(item => item.text === undefined))).toBe(true);
|
|
expect(complete.mock.calls[0][0]).toContain('governs the same security subject');
|
|
expect(complete.mock.calls[0][1]).toContain('Synthetic semantic evidence.');
|
|
expect(complete.mock.calls[0][1]).toContain('SMB signing is not required');
|
|
expect(complete.mock.calls[0][1]).not.toContain(resultTableAsset);
|
|
expect(complete.mock.calls[0][1]).not.toContain('DC02.other.local');
|
|
expect(complete.mock.calls[0][1]).not.toContain('A.5.1');
|
|
|
|
// A decision made in project-1 is visible to project-2 (global decisions).
|
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '5.1', decision: 'accepted' });
|
|
const afterDecision = await svc.get('project-2', 'scanner-finding:occurrence-2');
|
|
expect(afterDecision.decisions.map(d => ({ controlId: d.controlId, decision: d.decision }))).toEqual([{ controlId: '5.1', decision: 'accepted' }]);
|
|
});
|
|
|
|
it('drops one ungrounded evidence id but keeps the otherwise-valid mapping', async () => {
|
|
const { svc } = await runWith({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'not-a-supplied-chunk'] }]
|
|
});
|
|
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
|
expect(completed.run?.status).toBe('succeeded');
|
|
expect(completed.run?.result?.mappings[0].evidenceChunkIds).toEqual(['semantic-5.1']);
|
|
expect(completed.run?.errorCode).toBeUndefined();
|
|
});
|
|
|
|
it('fails safely with a structural detail when the model maps to an unknown control', async () => {
|
|
const { svc } = await runWith({
|
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: 'A.5.1', relationship: 'primary', confidence: 'high',
|
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1'] }]
|
|
});
|
|
const completed = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
|
expect(completed.run?.status).toBe('failed');
|
|
expect(completed.run?.errorCode).toBe('regulatory_mapping_invalid_output');
|
|
expect(completed.run?.errorDetail).toContain('unknown candidate control');
|
|
expect(completed.run?.errorDetail).not.toContain(resultTableAsset);
|
|
});
|
|
});
|
|
|
|
async function runWith(value: unknown): Promise<{ svc: RegulatoryMappingService }> {
|
|
const repository = mockRepository([finding()]);
|
|
const svc = service(repository, vi.fn(async () => ({ value })));
|
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
|
await svc.close();
|
|
return { svc };
|
|
}
|