538 lines
26 KiB
TypeScript
538 lines
26 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type {
|
|
Assessment,
|
|
AssessmentProject,
|
|
FindingDefinition,
|
|
FindingOccurrence,
|
|
ImportFileRecord,
|
|
ScanRun
|
|
} from '../../src/domain/canonical-model';
|
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
|
|
|
const timestamp = '2026-07-22T10:00:00.000Z';
|
|
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
|
|
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' };
|
|
const project: AssessmentProject = {
|
|
id: 'project-results',
|
|
name: 'Synthetic project',
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
attributes: {}
|
|
};
|
|
const assessment: Assessment = {
|
|
id: 'assessment-results',
|
|
projectId: project.id,
|
|
name: 'Synthetic assessment',
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
|
|
function run(id: string, sequence: number, toolId: string, toolName = toolId): ScanRun {
|
|
return {
|
|
id,
|
|
assessmentId: assessment.id,
|
|
sequence,
|
|
label: id,
|
|
tool: { id: toolId, name: toolName },
|
|
importedAt: timestamp
|
|
};
|
|
}
|
|
|
|
function file(scanRun: ScanRun): ImportFileRecord {
|
|
return {
|
|
id: `file-${scanRun.id}`,
|
|
scanRunId: scanRun.id,
|
|
fileName: `${scanRun.id}.csv`,
|
|
format: 'CSV',
|
|
sizeBytes: 10,
|
|
importedAt: timestamp,
|
|
detectedTool: scanRun.tool,
|
|
detection: confidence,
|
|
processedWith: {
|
|
parserId: 'csv',
|
|
parserVersion: '1.0.0',
|
|
profileId: scanRun.tool.id,
|
|
profileVersion: '1.0.0',
|
|
mappingVersion: '1.0.0'
|
|
},
|
|
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
|
|
};
|
|
}
|
|
|
|
function definition(id: string, key: string, category: string): FindingDefinition {
|
|
return {
|
|
id,
|
|
canonicalKey: key,
|
|
identity: { version: 1, strategy: key.includes('unresolved') ? 'unresolved' : 'tool-rule-id', durable: !key.includes('unresolved'), evidence: ['synthetic'] },
|
|
title: 'Same visible title',
|
|
category,
|
|
frameworkReferences: [],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
}
|
|
|
|
function occurrence(
|
|
id: string,
|
|
scanRun: ScanRun,
|
|
definitionId: string,
|
|
status: 'failed' | 'error',
|
|
originalStatus: string,
|
|
ruleId?: string
|
|
): FindingOccurrence {
|
|
return {
|
|
id,
|
|
definitionId,
|
|
scanRunId: scanRun.id,
|
|
importFileId: `file-${scanRun.id}`,
|
|
sourceIdentity: {
|
|
tool: scanRun.tool,
|
|
originalRuleId: ruleId,
|
|
canonicalRuleId: ruleId,
|
|
originalTitle: 'Same visible title'
|
|
},
|
|
sourcePosition: { rowIndex: 1 },
|
|
originalFields: [
|
|
{ key: 'Status', label: 'Status', value: originalStatus, columnIndex: 0 },
|
|
{ key: 'Category', label: 'Category', value: scanRun.tool.id === 'pingcastle' ? 'Privileged Accounts' : 'Account Security', columnIndex: 1 }
|
|
],
|
|
fieldMappings: [{
|
|
targetRole: 'category',
|
|
sourceFieldKeys: ['Category'],
|
|
normalizedValue: scanRun.tool.id === 'pingcastle' ? 'Privileged Accounts' : 'Account Security',
|
|
provenance
|
|
}],
|
|
severity: { originalValue: 'High', normalizedValue: 'high', provenance },
|
|
status: { originalValue: originalStatus, normalizedValue: status, provenance }
|
|
};
|
|
}
|
|
|
|
async function commit(
|
|
repository: LocalAssessorRepository,
|
|
scanRun: ScanRun,
|
|
definitions: FindingDefinition[],
|
|
occurrences: FindingOccurrence[]
|
|
): Promise<void> {
|
|
const session = await repository.beginImport({
|
|
sessionId: `session-${scanRun.id}`,
|
|
projectId: project.id,
|
|
assessmentId: assessment.id,
|
|
scanRun
|
|
});
|
|
await session.stage({
|
|
importFiles: [file(scanRun)],
|
|
findingDefinitions: definitions,
|
|
findingOccurrences: occurrences
|
|
});
|
|
await session.commit();
|
|
}
|
|
|
|
describe('Unified result read model', () => {
|
|
it('shows the native public PingCastle rule ID and report title through the normal Result DB query', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
const scanRun = run('run-pingcastle-ntlm', 1, 'pingcastle');
|
|
const finding = {
|
|
...definition('definition-pingcastle-ntlm', 'pingcastle:S-oldNtlm', 'Anomalies'),
|
|
title: 'The LAN Manager Authentication Level allows the use of NTLMv1 or LM.'
|
|
};
|
|
await commit(repository, scanRun, [finding], [
|
|
occurrence('occurrence-pingcastle-ntlm', scanRun, finding.id, 'failed', 'Matched', 'S-oldNtlm')
|
|
]);
|
|
|
|
const result = await repository.queryUnifiedResults({ projectId: project.id, scanRunIds: [scanRun.id] });
|
|
expect(result).toMatchObject({
|
|
total: 1,
|
|
items: [{ title: finding.title, sourceRuleId: 'S-oldNtlm', sourceTool: 'pingcastle' }]
|
|
});
|
|
await expect(repository.getUnifiedResultById('scanner-finding:occurrence-pingcastle-ntlm'))
|
|
.resolves.toMatchObject({ title: finding.title, sourceRuleId: 'S-oldNtlm' });
|
|
});
|
|
|
|
it('keeps Purple Knight AD and Entra ID separately filterable in Result DB', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
const adRun = run('run-purple-ad', 1, 'purple-knight', 'Purple Knight for Active Directory');
|
|
const entraRun = run('run-purple-entra', 2, 'purple-knight', 'Purple Knight for Entra ID');
|
|
const combinedRun = run('run-purple-combined', 3, 'purple-knight', 'Purple Knight for Active Directory and Entra ID');
|
|
const adDefinition = definition('definition-purple-ad', 'purple-knight:ad', 'AD');
|
|
const entraDefinition = definition('definition-purple-entra', 'purple-knight:entra', 'Entra');
|
|
const combinedDefinition = definition('definition-purple-combined', 'purple-knight:combined', 'Combined');
|
|
await commit(repository, adRun, [adDefinition], [occurrence('occurrence-purple-ad', adRun, adDefinition.id, 'failed', 'Failed', 'SI000001')]);
|
|
await commit(repository, entraRun, [entraDefinition], [occurrence('occurrence-purple-entra', entraRun, entraDefinition.id, 'failed', 'Failed', 'SI000002')]);
|
|
await commit(repository, combinedRun, [combinedDefinition], [occurrence('occurrence-purple-combined', combinedRun, combinedDefinition.id, 'failed', 'Failed', 'SI000003')]);
|
|
|
|
const facets = await repository.queryUnifiedResultFacets({ deviation: true });
|
|
expect(facets.facets.find(facet => facet.id === 'sourceTool')?.values).toEqual([
|
|
{ value: 'purple-knight-ad', label: 'Purple Knight for Active Directory', count: 1 },
|
|
{ value: 'purple-knight-combined', label: 'Purple Knight for Active Directory and Entra ID', count: 1 },
|
|
{ value: 'purple-knight-entra', label: 'Purple Knight for Entra ID', count: 1 }
|
|
]);
|
|
await expect(repository.queryUnifiedResults({ sourceTool: ['purple-knight-entra'], deviation: true })).resolves.toMatchObject({
|
|
total: 1,
|
|
items: [expect.objectContaining({ sourceResultId: 'occurrence-purple-entra', sourceTool: 'purple-knight-entra', sourceToolScope: 'entra-id' })]
|
|
});
|
|
});
|
|
|
|
it('keeps occurrences separate, splits status dimensions and exposes namespaced categories and reviews', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
|
|
const firstRun = run('run-pingcastle', 1, 'pingcastle');
|
|
const secondRun = run('run-purple-knight', 2, 'purple-knight');
|
|
const stable = definition('definition-stable', 'pingcastle:pc-1', 'Privileged Accounts');
|
|
const unresolved = definition('definition-unresolved', 'unresolved:run-purple-knight:1', 'Account Security');
|
|
await commit(repository, firstRun, [stable], [
|
|
occurrence('occurrence-1', firstRun, stable.id, 'failed', 'FAILED', 'PC-1')
|
|
]);
|
|
await commit(repository, secondRun, [unresolved], [
|
|
occurrence('occurrence-2', secondRun, unresolved.id, 'error', 'Failed to run')
|
|
]);
|
|
await repository.saveReview({
|
|
id: 'review-result-1',
|
|
target: { kind: 'occurrence', occurrenceId: 'occurrence-1' },
|
|
workflowStatus: 'accepted',
|
|
priority: 'high',
|
|
includeInActionPlan: false,
|
|
owner: 'synthetic-owner',
|
|
markers: [],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
revision: 1
|
|
}, 0);
|
|
|
|
const all = await repository.queryUnifiedResults({ projectId: project.id });
|
|
expect(all.total).toBe(2);
|
|
expect(all.items.map(item => item.sourceResultId)).toEqual(['occurrence-1', 'occurrence-2']);
|
|
|
|
const currentTimeslot = await repository.queryUnifiedResults({
|
|
projectId: project.id,
|
|
scanRunIds: [secondRun.id]
|
|
});
|
|
expect(currentTimeslot.items.map(item => item.sourceResultId)).toEqual(['occurrence-2']);
|
|
|
|
const first = await repository.getUnifiedResultById('scanner-finding:occurrence-1');
|
|
expect(first).toMatchObject({
|
|
sourceKind: 'scanner-finding',
|
|
severityNormalized: 'high',
|
|
outcomeOriginal: 'fail',
|
|
outcomeNormalized: 'fail',
|
|
technicalExecutionState: 'completed',
|
|
resultType: 'exposure_finding',
|
|
isIoe: true,
|
|
exposureState: 'detected',
|
|
lifecycleState: 'unknown',
|
|
reviewStatus: 'resolved',
|
|
reviewDisposition: 'accepted',
|
|
categories: [{ namespace: 'source-tool', categoryLabel: 'PingCastle' }]
|
|
});
|
|
expect(first).not.toHaveProperty('reviewOwner');
|
|
|
|
const executionError = await repository.queryUnifiedResults({
|
|
outcome: ['execution_error'],
|
|
technicalExecutionState: ['failed']
|
|
});
|
|
expect(executionError.items.map(item => item.sourceResultId)).toEqual(['occurrence-2']);
|
|
expect(executionError.items[0].outcomeOriginal).toBe('execution_error');
|
|
// Ein technischer Ausführungsfehler ist per RESULT_CONTRACT nie is_ioe=true,
|
|
// auch wenn das Werkzeug ein exposure_finding-Werkzeug ist.
|
|
expect(executionError.items[0]).toMatchObject({
|
|
resultType: 'exposure_finding',
|
|
isIoe: false,
|
|
exposureState: 'execution_error'
|
|
});
|
|
|
|
const ioeOnly = await repository.queryUnifiedResults({ isIoe: true });
|
|
expect(ioeOnly.items.map(item => item.sourceResultId)).toEqual(['occurrence-1']);
|
|
|
|
const complianceOnly = await repository.queryUnifiedResults({ resultType: ['compliance_check'] });
|
|
expect(complianceOnly.total).toBe(0);
|
|
|
|
const categories = await repository.listResultCategories();
|
|
expect(categories.map(category => [category.namespace, category.categoryLabel])).toEqual([
|
|
['source-tool', 'PingCastle'],
|
|
['source-tool', 'Purple Knight']
|
|
]);
|
|
|
|
const facets = await repository.queryUnifiedResultFacets({ severity: ['high'] });
|
|
expect(facets.total).toBe(2);
|
|
expect(facets.facets.find(facet => facet.id === 'outcome')?.values).toEqual([
|
|
{ value: 'execution_error', label: 'execution_error', count: 1 },
|
|
{ value: 'fail', label: 'fail', count: 1 }
|
|
]);
|
|
expect(facets.facets.find(facet => facet.id === 'resultType')?.values).toEqual([
|
|
{ value: 'exposure_finding', label: 'exposure_finding', count: 2 }
|
|
]);
|
|
expect(facets.facets.find(facet => facet.id === 'isIoe')?.values).toEqual([
|
|
{ value: 'false', label: 'false', count: 1 },
|
|
{ value: 'true', label: 'true', count: 1 }
|
|
]);
|
|
});
|
|
|
|
it('marks a failed CIS-CAT compliance check as deviation without treating it as IOE', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
const scanRun = run('run-cis-cat', 1, 'cis-cat');
|
|
const finding = {
|
|
...definition('definition-cis-cat', 'cis-cat:2.2.2', 'CIS'),
|
|
title: "2.2.2 Ensure 'Access this computer from the network' is configured"
|
|
};
|
|
await commit(repository, scanRun, [finding], [
|
|
occurrence('occurrence-cis-cat', scanRun, finding.id, 'failed', 'Fail', '2.2.2')
|
|
]);
|
|
|
|
const result = await repository.getUnifiedResultById('scanner-finding:occurrence-cis-cat');
|
|
expect(result).toMatchObject({
|
|
title: "2.2.2 Ensure 'Access this computer from the network' is configured",
|
|
outcomeNormalized: 'fail',
|
|
resultType: 'compliance_check',
|
|
isIoe: false,
|
|
isDeviation: true
|
|
});
|
|
await expect(repository.queryUnifiedResults({ sourceTool: ['cis-cat'], deviation: true })).resolves.toMatchObject({
|
|
total: 1,
|
|
items: [expect.objectContaining({ sourceResultId: 'occurrence-cis-cat' })]
|
|
});
|
|
});
|
|
|
|
it('exposes only privacy-approved PingCastle environment observations in Result DB', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
const scanRun = run('run-pingcastle-environment', 1, 'pingcastle');
|
|
const importFile: ImportFileRecord = {
|
|
...file(scanRun),
|
|
format: 'HTML',
|
|
fileName: 'synthetic-pingcastle.html',
|
|
processedWith: {
|
|
parserId: 'html-report', parserVersion: '1.1.0', profileId: 'pingcastle', profileVersion: '1.6.0',
|
|
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
|
|
compatibilityKey: 'pingcastle-canonical-v1.1'
|
|
}
|
|
};
|
|
const session = await repository.beginImport({
|
|
sessionId: 'session-pingcastle-environment', projectId: project.id, assessmentId: assessment.id, scanRun
|
|
});
|
|
await session.stage({
|
|
importFiles: [importFile],
|
|
environmentObservations: [{
|
|
version: 1, id: 'environment-dfl', scanRunId: scanRun.id, importFileId: importFile.id, tool: scanRun.tool,
|
|
subject: { type: 'domain-information', identity: 'synthetic.example' },
|
|
property: 'domain.functional_level', label: 'Domain functional level',
|
|
originalValue: 'Windows Server 2016', normalizedValue: 2016, valueType: 'number', state: 'observed',
|
|
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' },
|
|
mappingVersion: '1.2.0+context-1.0.0', attributes: { extractionMethod: 'synthetic-fixture' }
|
|
}, {
|
|
version: 1, id: 'environment-domain-name', scanRunId: scanRun.id, importFileId: importFile.id, tool: scanRun.tool,
|
|
subject: { type: 'domain-information', identity: 'private.synthetic.example' },
|
|
property: 'domain.fqdn', label: 'Domain', originalValue: 'private.synthetic.example',
|
|
normalizedValue: 'private.synthetic.example', valueType: 'string', state: 'observed',
|
|
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/1' },
|
|
mappingVersion: '1.2.0+context-1.0.0', attributes: { customerMarker: 'private-synthetic' }
|
|
}]
|
|
});
|
|
await session.commit();
|
|
|
|
const results = await repository.queryUnifiedResults({
|
|
projectId: project.id,
|
|
sourceKind: ['environment-observation'],
|
|
environmentProperty: ['domain.functional_level'],
|
|
environmentSubjectType: ['active-directory-environment'],
|
|
environmentState: ['observed'],
|
|
search: '2016'
|
|
});
|
|
expect(results).toMatchObject({
|
|
total: 1,
|
|
items: [expect.objectContaining({
|
|
resultProjectionId: 'environment-observation:environment-dfl',
|
|
sourceKind: 'environment-observation',
|
|
sourceTool: 'pingcastle',
|
|
sourceProfileVersion: '1.6.0',
|
|
mappingRuleVersion: '1.2.0+context-1.0.0',
|
|
processingStatus: 'current',
|
|
environmentProperty: 'domain.functional_level',
|
|
environmentSubjectType: 'active-directory-environment',
|
|
environmentNormalizedValue: 2016,
|
|
environmentValueType: 'number',
|
|
environmentState: 'observed',
|
|
supplementalDisclosurePolicyVersion: '1.0.0',
|
|
supplementalDisclosureClass: 'technical-metadata',
|
|
resultType: 'informational',
|
|
isIoe: false,
|
|
isDeviation: false
|
|
})]
|
|
});
|
|
await expect(repository.getUnifiedResultById('environment-observation:environment-dfl'))
|
|
.resolves.toMatchObject({ sourceResultId: 'environment-dfl', environmentNormalizedValue: 2016 });
|
|
await expect(repository.getUnifiedResultById('environment-observation:environment-domain-name'))
|
|
.resolves.toBeNull();
|
|
expect(JSON.stringify(results)).not.toContain('synthetic.example');
|
|
expect(JSON.stringify(results)).not.toContain('private-synthetic');
|
|
expect(results.items[0]).not.toHaveProperty('sourceLocator');
|
|
expect(results.items[0]).not.toHaveProperty('environmentOriginalValue');
|
|
expect(results.items[0]).not.toHaveProperty('environmentSubjectIdentity');
|
|
expect(results.items[0]).not.toHaveProperty('environmentAttributes');
|
|
await expect(repository.queryUnifiedResults({ sourceKind: ['environment-observation'], deviation: true }))
|
|
.resolves.toMatchObject({ total: 0 });
|
|
|
|
const facets = await repository.queryUnifiedResultFacets({ projectId: project.id });
|
|
expect(facets.facets.find(facet => facet.id === 'sourceKind')?.values)
|
|
.toContainEqual(expect.objectContaining({ value: 'environment-observation', count: 1 }));
|
|
expect(facets.facets.find(facet => facet.id === 'environmentProperty')?.values)
|
|
.toContainEqual(expect.objectContaining({ value: 'domain.functional_level', count: 1 }));
|
|
expect(facets.facets.find(facet => facet.id === 'environmentState')?.values)
|
|
.toContainEqual(expect.objectContaining({ value: 'observed', count: 1 }));
|
|
expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values)
|
|
.toContainEqual(expect.objectContaining({ value: 'active-directory-environment', count: 1 }));
|
|
|
|
const categories = await repository.listResultCategories({ sourceKind: ['environment-observation'] });
|
|
expect(categories).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ namespace: 'environment-subject', categoryCode: 'active-directory-environment' }),
|
|
expect.objectContaining({ namespace: 'environment-property', categoryCode: 'domain' })
|
|
]));
|
|
});
|
|
|
|
it('does not merge equal titles without a durable identity', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
const scanRun = run('run-unresolved', 1, 'generic');
|
|
const left = definition('definition-left', 'unresolved:left', 'Synthetic');
|
|
const right = definition('definition-right', 'unresolved:right', 'Synthetic');
|
|
await commit(repository, scanRun, [left, right], [
|
|
occurrence('occurrence-left', scanRun, left.id, 'failed', 'Fail'),
|
|
occurrence('occurrence-right', scanRun, right.id, 'failed', 'Fail')
|
|
]);
|
|
|
|
const results = await repository.queryUnifiedResults({ search: 'Security assessment finding' });
|
|
expect(results.total).toBe(2);
|
|
expect(new Set(results.items.map(item => item.sourceResultId))).toEqual(
|
|
new Set(['occurrence-left', 'occurrence-right'])
|
|
);
|
|
expect(JSON.stringify(results)).not.toContain('Same visible title');
|
|
expect(results.items.every(item => item.canonicalDefinitionId === undefined)).toBe(true);
|
|
});
|
|
|
|
it('filters and facets by mapping rule version and source profile version', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
|
|
const firstRun = run('run-mapping-a', 1, 'pingcastle');
|
|
const secondRun = run('run-mapping-b', 2, 'purple-knight');
|
|
const definitionA = definition('definition-mapping-a', 'pingcastle:mapping-a', 'Privileged Accounts');
|
|
const definitionB = definition('definition-mapping-b', 'purple-knight:mapping-b', 'Account Security');
|
|
|
|
const fileA: ImportFileRecord = { ...file(firstRun), processedWith: { ...file(firstRun).processedWith, profileVersion: '1.0.0', mappingVersion: '1.0.0' } };
|
|
const fileB: ImportFileRecord = { ...file(secondRun), processedWith: { ...file(secondRun).processedWith, profileVersion: '2.0.0', mappingVersion: '2.1.0' } };
|
|
|
|
async function commitWithFile(scanRun: ScanRun, importFile: ImportFileRecord, defs: FindingDefinition[], occurrences: FindingOccurrence[]): Promise<void> {
|
|
const session = await repository.beginImport({ sessionId: `session-${scanRun.id}`, projectId: project.id, assessmentId: assessment.id, scanRun });
|
|
await session.stage({ importFiles: [importFile], findingDefinitions: defs, findingOccurrences: occurrences });
|
|
await session.commit();
|
|
}
|
|
|
|
await commitWithFile(firstRun, fileA, [definitionA], [occurrence('occurrence-mapping-a', firstRun, definitionA.id, 'failed', 'FAILED', 'PC-M1')]);
|
|
await commitWithFile(secondRun, fileB, [definitionB], [occurrence('occurrence-mapping-b', secondRun, definitionB.id, 'failed', 'FAILED', 'PK-M1')]);
|
|
|
|
const onlyNewMapping = await repository.queryUnifiedResults({ mappingRuleVersion: ['2.1.0'] });
|
|
expect(onlyNewMapping.items.map(item => item.sourceResultId)).toEqual(['occurrence-mapping-b']);
|
|
|
|
const onlyOldProfile = await repository.queryUnifiedResults({ sourceProfileVersion: ['1.0.0'] });
|
|
expect(onlyOldProfile.items.map(item => item.sourceResultId)).toEqual(['occurrence-mapping-a']);
|
|
|
|
const facets = await repository.queryUnifiedResultFacets({});
|
|
expect(facets.facets.find(facet => facet.id === 'mappingRuleVersion')?.values).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ value: '1.0.0', count: 1 }),
|
|
expect.objectContaining({ value: '2.1.0', count: 1 })
|
|
])
|
|
);
|
|
expect(facets.facets.find(facet => facet.id === 'sourceProfileVersion')?.values).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ value: '1.0.0', count: 1 }),
|
|
expect.objectContaining({ value: '2.0.0', count: 1 })
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps all options of a facet visible even when filtering by that same facet', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
|
|
const scanRun = run('run-severity-visibility', 1, 'pingcastle');
|
|
const definitionCritical = definition('definition-severity-critical', 'pingcastle:severity-critical', 'Privileged Accounts');
|
|
const definitionHigh = definition('definition-severity-high', 'pingcastle:severity-high', 'Privileged Accounts');
|
|
const criticalOccurrence: FindingOccurrence = {
|
|
...occurrence('occurrence-severity-critical', scanRun, definitionCritical.id, 'failed', 'FAILED', 'SEV-C'),
|
|
severity: { originalValue: 'Critical', normalizedValue: 'critical', provenance }
|
|
};
|
|
const highOccurrence = occurrence('occurrence-severity-high', scanRun, definitionHigh.id, 'failed', 'FAILED', 'SEV-H');
|
|
|
|
await commit(repository, scanRun, [definitionCritical, definitionHigh], [criticalOccurrence, highOccurrence]);
|
|
|
|
const facetsFilteredToCritical = await repository.queryUnifiedResultFacets({ severity: ['critical'] });
|
|
expect(facetsFilteredToCritical.total).toBe(1);
|
|
const severityFacet = facetsFilteredToCritical.facets.find(facet => facet.id === 'severity');
|
|
expect(severityFacet?.values.map(value => value.value).sort()).toEqual(['critical', 'high']);
|
|
expect(severityFacet?.values.find(value => value.value === 'critical')?.count).toBe(1);
|
|
expect(severityFacet?.values.find(value => value.value === 'high')?.count).toBe(1);
|
|
});
|
|
|
|
// Ohne diese Reihenfolge muesste je Werkzeug nach einer eigenen Punktespalte
|
|
// sortiert werden (Purple Knight: Weight, PingCastle: Points). Ueber die
|
|
// kanonische Severity steht die Kritikalitaet fuer alle Berichte gleich oben.
|
|
it('returns the most critical findings first, across tools', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
|
|
const scanRun = run('run-severity-order', 1, 'purple-knight');
|
|
const severities: Array<[string, 'critical' | 'low' | 'high' | 'medium']> = [
|
|
['low', 'low'], ['critical', 'critical'], ['medium', 'medium'], ['high', 'high']
|
|
];
|
|
const definitions = severities.map(([name]) =>
|
|
definition(`definition-order-${name}`, `purple-knight:order-${name}`, 'Account Security'));
|
|
const occurrences = severities.map(([name, severity], index) => ({
|
|
...occurrence(`occurrence-order-${name}`, scanRun, definitions[index].id, 'failed', 'IOEs found', `ORD-${name}`),
|
|
severity: { originalValue: severity, normalizedValue: severity, provenance }
|
|
}));
|
|
|
|
await commit(repository, scanRun, definitions, occurrences);
|
|
|
|
const results = await repository.queryUnifiedResults({ projectId: project.id });
|
|
expect(results.items.map(item => item.severityNormalized)).toEqual(['critical', 'high', 'medium', 'low']);
|
|
});
|
|
|
|
it('removes unavailable options of other facets after applying active filters', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
|
|
const scanRun = run('run-cross-facet-visibility', 1, 'pingcastle');
|
|
const definitionCriticalFail = definition('definition-cross-critical-fail', 'pingcastle:cross-critical-fail', 'Privileged Accounts');
|
|
const definitionHighError = definition('definition-cross-high-error', 'pingcastle:cross-high-error', 'Privileged Accounts');
|
|
const criticalFailOccurrence: FindingOccurrence = {
|
|
...occurrence('occurrence-cross-critical-fail', scanRun, definitionCriticalFail.id, 'failed', 'FAILED', 'CROSS-1'),
|
|
severity: { originalValue: 'Critical', normalizedValue: 'critical', provenance }
|
|
};
|
|
const highErrorOccurrence = occurrence('occurrence-cross-high-error', scanRun, definitionHighError.id, 'error', 'Failed to run', 'CROSS-2');
|
|
|
|
await commit(repository, scanRun, [definitionCriticalFail, definitionHighError], [criticalFailOccurrence, highErrorOccurrence]);
|
|
|
|
// Nur nach severity=critical gefiltert; die zweite Occurrence (severity high,
|
|
// outcome execution_error) ist damit auch keine erreichbare Filteroption.
|
|
const facets = await repository.queryUnifiedResultFacets({ severity: ['critical'] });
|
|
expect(facets.total).toBe(1);
|
|
const outcomeFacet = facets.facets.find(facet => facet.id === 'outcome');
|
|
expect(outcomeFacet?.values.map(value => value.value)).toEqual(['fail']);
|
|
expect(outcomeFacet?.values.find(value => value.value === 'fail')?.count).toBe(1);
|
|
expect(outcomeFacet?.values.find(value => value.value === 'execution_error')).toBeUndefined();
|
|
});
|
|
});
|