142 lines
8.9 KiB
TypeScript
142 lines
8.9 KiB
TypeScript
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import type { Server } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { createAssessorHttpServer } from '../../server/assessor-http-server';
|
|
import type { CanonicalFindingStatus, FindingOccurrence, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
|
|
import type { EnvironmentObservation } from '../../src/domain/environment-context';
|
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
|
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
|
|
import type { TimeslotDomainComparison } from '../../src/comparison/timeslot-domain-comparison';
|
|
import { securityDomainCatalog } from '../../src/evaluation/security-domains';
|
|
|
|
// The Comparison Engine route reads two synthetic timeslots through its read-only port.
|
|
const stamp = '2026-01-01T08:00:00.000Z';
|
|
const tool = { id: 'cis-cat', name: 'CIS-CAT Pro' };
|
|
const provenance = { decisionSource: 'source-native' as const, confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] }, mappingVersion: '1' };
|
|
let directory: string;
|
|
let server: Server | undefined;
|
|
|
|
beforeEach(async () => {
|
|
directory = await mkdtemp(join(tmpdir(), 'assessor-comparison-http-'));
|
|
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
|
|
});
|
|
afterEach(async () => {
|
|
await new Promise(resolve => server ? server.close(resolve) : resolve(undefined));
|
|
server = undefined;
|
|
await rm(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
function importFile(runId: string, importedAt: string): ImportFileRecord {
|
|
return {
|
|
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML', sizeBytes: 10, importedAt,
|
|
detectedTool: tool, detection: { level: 'high', score: 1, reasons: ['synthetic'] },
|
|
processedWith: { parserId: 'html', parserVersion: '1', profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0',
|
|
normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1' },
|
|
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
|
|
};
|
|
}
|
|
|
|
function observation(runId: string, property: string, value: string): EnvironmentObservation {
|
|
return {
|
|
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`, tool, subject: { type: 'host' },
|
|
property, label: property, originalValue: value, normalizedValue: value,
|
|
valueType: property === 'report.generated_at' ? 'datetime' : 'string', state: 'observed', source: {}, mappingVersion: '1', attributes: {}
|
|
};
|
|
}
|
|
|
|
function occurrence(runId: string, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
|
|
const outcome = status === 'passed' ? 'pass' : 'fail';
|
|
return {
|
|
id: `${runId}-${ruleId}`, definitionId: `definition-${ruleId}`, scanRunId: runId, importFileId: `file-${runId}`,
|
|
sourceIdentity: { tool, canonicalRuleId: ruleId }, sourcePosition: { rowIndex: 1 },
|
|
originalFields: [{ key: 'rule', label: 'Rule', columnIndex: 0, value: ruleId },
|
|
{ key: 'result', label: 'Result', columnIndex: 1, value: status === 'passed' ? 'Pass' : 'Fail' }], fieldMappings: [],
|
|
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
|
|
status: { originalValue: status, normalizedValue: status, provenance },
|
|
outcome: { originalValue: outcome, normalizedValue: outcome, provenance }
|
|
};
|
|
}
|
|
|
|
async function timeslot(repository: LocalAssessorRepository, name: string, observedAt: string, findings: Array<[string, CanonicalFindingStatus]>) {
|
|
const assessmentId = `assessment-${name}`;
|
|
await repository.saveAssessment({ id: assessmentId, projectId: 'dummy-project', name, createdAt: observedAt, updatedAt: observedAt, effectiveAt: observedAt });
|
|
const scanRun: ScanRun = { id: `run-${name}`, assessmentId, sequence: 1, label: name, tool, importedAt: observedAt };
|
|
const session = await repository.beginImport({ sessionId: `session-${name}`, projectId: 'dummy-project', assessmentId, scanRun });
|
|
await session.stage({
|
|
importFiles: [importFile(scanRun.id, observedAt)],
|
|
environmentObservations: [observation(scanRun.id, 'host.name', 'server-a'), observation(scanRun.id, 'benchmark.name', 'benchmark-a'),
|
|
observation(scanRun.id, 'report.generated_at', observedAt), observation(scanRun.id, 'report.engine_version', '4.0.0')],
|
|
findingDefinitions: findings.map(([ruleId]) => ({ id: `definition-${ruleId}`, canonicalKey: `cis-cat:${ruleId}`, title: ruleId,
|
|
frameworkReferences: [], createdAt: stamp, updatedAt: stamp })),
|
|
findingOccurrences: findings.map(([ruleId, status]) => occurrence(scanRun.id, ruleId, status))
|
|
});
|
|
await session.commit();
|
|
}
|
|
|
|
async function start() {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
|
|
await timeslot(repository, 'a', '2026-01-01T10:00:00.000Z', [['1.1.1', 'open'], ['1.1.2', 'open'], ['5.1', 'passed']]);
|
|
await timeslot(repository, 'b', '2026-02-01T10:00:00.000Z', [['1.1.1', 'passed'], ['1.1.2', 'open'], ['5.1', 'open']]);
|
|
server = createAssessorHttpServer({ repository, staticRoot: directory });
|
|
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return { repository, base: `http://127.0.0.1:${port}/api/v1/projects/dummy-project/timeslot-comparison` };
|
|
}
|
|
|
|
const query = (a: string, b: string) => `?baselineAssessmentIds=assessment-${a}&baselineScanRunIds=run-${a}&targetAssessmentIds=assessment-${b}&targetScanRunIds=run-${b}`;
|
|
|
|
describe('Comparison Engine timeslot route', () => {
|
|
it('compares two stored timeslots read-only', async () => {
|
|
const { repository, base } = await start();
|
|
const revision = repository.revision;
|
|
const response = await fetch(base + query('a', 'b'));
|
|
expect(response.status).toBe(200);
|
|
const report = await response.json() as TimeslotComparisonReport;
|
|
expect(report.summary).toMatchObject({ resolved: 1, unchanged: 1, regressed: 1, new: 0 });
|
|
expect(report.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ baselineScore: 100 / 3, targetScore: 100 / 3, change: 0 });
|
|
// The route reads both Work datasets, so every scanner change carries the Finding Reader's Problem values of A and B.
|
|
expect(report.changes.every(item => item.problem)).toBe(true);
|
|
expect(repository.revision).toBe(revision);
|
|
});
|
|
|
|
it('compares Security Domains over Client Info records and places each change in its domain', async () => {
|
|
const { repository, base } = await start();
|
|
const revision = repository.revision;
|
|
const response = await fetch(`${base}/domains${query('a', 'b')}`);
|
|
expect(response.status).toBe(200);
|
|
const result = await response.json() as TimeslotDomainComparison;
|
|
expect(result.sources).toEqual(['cis-cat']);
|
|
expect(result.domains.map(domain => domain.id)).toEqual(securityDomainCatalog.map(domain => domain.id));
|
|
const sum = (pick: (domain: TimeslotDomainComparison['domains'][number]) => number) => result.domains.reduce((total, domain) => total + pick(domain), 0);
|
|
// A: two deviations and one pass; B: one pass and two deviations; one resolved and one regressed.
|
|
expect([sum(domain => domain.baseline.passed), sum(domain => domain.baseline.deviations)]).toEqual([1, 2]);
|
|
expect([sum(domain => domain.target.passed), sum(domain => domain.target.deviations)]).toEqual([1, 2]);
|
|
expect([sum(domain => domain.resolved), sum(domain => domain.worse)]).toEqual([1, 1]);
|
|
// A repeated request of the same revision is served from the cache with the same content.
|
|
expect(await (await fetch(`${base}/domains${query('a', 'b')}`)).json()).toEqual(result);
|
|
expect(repository.revision).toBe(revision);
|
|
});
|
|
|
|
it('rejects a reversed order, a missing timeslot and an assessment of another project', async () => {
|
|
const { base } = await start();
|
|
expect((await fetch(base + query('b', 'a'))).status).toBe(400);
|
|
expect((await fetch(`${base}?baselineAssessmentIds=assessment-a`)).status).toBe(400);
|
|
const foreign = await fetch(`${base}?baselineAssessmentIds=assessment-x&targetAssessmentIds=assessment-b`);
|
|
expect(foreign.status).toBe(404);
|
|
});
|
|
|
|
it('can be switched off', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
|
|
server = createAssessorHttpServer({ repository, staticRoot: directory, comparisonPort: null });
|
|
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/v1/projects/dummy-project/timeslot-comparison${query('a', 'b')}`);
|
|
expect(response.status).toBe(503);
|
|
});
|
|
});
|