189 lines
8.6 KiB
TypeScript
189 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { defaultReview } from '../../src/domain/review';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
|
import { purpleKnightProfile } from '../../src/profiles/purple-knight/purple-knight-profile';
|
|
import { cisCatProfile } from '../../src/profiles/cis-cat/cis-cat-profile';
|
|
import { deriveDynamicFacets, recountFacetsAgainstActiveFilters } from '../../src/features/search-filter/dynamic-facets';
|
|
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
|
|
|
|
const sheet = makeSheet('Findings', [
|
|
['Title', 'Severity', 'Status', 'Score', 'Description'],
|
|
['A', 'High', 'Open', '9', 'A'.repeat(120)],
|
|
['B', 'High', 'Open', '7', 'B'.repeat(120)],
|
|
['C', 'Medium', 'Closed', '5', 'C'.repeat(120)],
|
|
['D', 'Low', 'Open', '2', 'D'.repeat(120)]
|
|
], '', 'generic.csv');
|
|
|
|
describe('dynamic facets', () => {
|
|
it('derives useful category filters but excludes score ranges and unsuitable free text', () => {
|
|
const facets = deriveDynamicFacets(sheet, null, genericProfile, {});
|
|
expect(facets.find(facet => facet.id === 'field:1')?.kind).toBe('category');
|
|
// Die rohe Statusspalte erscheint nicht mehr als eigener Filter: seit das
|
|
// generische Profil ein Statusvokabular besitzt, uebernimmt die Facette
|
|
// "Ergebnisstatus" diese Aufgabe. Zwei Filter fuer dieselbe Spalte waren
|
|
// fuer die Bedienung nicht unterscheidbar.
|
|
expect(facets.some(facet => facet.id === 'field:2')).toBe(false);
|
|
expect(facets.find(facet => facet.id === 'profile:status')?.values.map(value => value.label)).toEqual(['Deviation']);
|
|
expect(facets.some(facet => facet.id === 'field:3')).toBe(false);
|
|
expect(facets.some(facet => facet.id === 'field:4')).toBe(false);
|
|
});
|
|
|
|
it('combines category facets, numeric ranges and full-text search', () => {
|
|
const records = filterAndSortRecords(sheet, {
|
|
activeProfile: genericProfile,
|
|
search: 'b',
|
|
sortColumn: 'all',
|
|
sortDirection: 'desc',
|
|
statusFilter: 'all',
|
|
facetFilters: {
|
|
'field:1': { values: ['high'] },
|
|
'field:3': { minimum: '6', maximum: '8' }
|
|
},
|
|
actionOnly: false,
|
|
reviews: {}
|
|
});
|
|
expect(records.map(record => record.title)).toEqual(['B']);
|
|
});
|
|
|
|
it('removes facet values that have no hits under the other active filters', () => {
|
|
const facets = deriveDynamicFacets(sheet, null, genericProfile, {});
|
|
const counted = recountFacetsAgainstActiveFilters({
|
|
facets,
|
|
records: sheet.analysis.records,
|
|
filters: {},
|
|
reviews: {},
|
|
statusFilter: 'deviation',
|
|
matchesStatus: (record, status) => genericProfile.matchesStatus(record, status)
|
|
});
|
|
|
|
expect(counted.flatMap(facet => facet.values).every(value => value.count > 0)).toBe(true);
|
|
expect(counted.find(facet => facet.id === 'field:1')?.values.map(value => value.label)).not.toContain('Medium');
|
|
});
|
|
|
|
it('exposes declarative Purple Knight status filters through the common facet model', () => {
|
|
const purpleSheet = makeSheet('Indicator Results', [
|
|
['Name', 'Weight', 'Result'],
|
|
['Finding A', '10', 'IOEs found'],
|
|
['Finding B', '2', 'Passed'],
|
|
['Finding C', '1', 'Not Relevant']
|
|
], '', 'purple-knight.csv');
|
|
const facet = deriveDynamicFacets(purpleSheet, null, purpleKnightProfile, {})[0];
|
|
expect(facet).toMatchObject({ id: 'profile:status', source: 'profile-status', label: 'Result Status' });
|
|
expect(facet.values.find(value => value.value === 'ioes-found')).toMatchObject({ count: 1 });
|
|
});
|
|
|
|
// Purple Knight verteilt mehrwertige MITRE-Zuordnungen auf nummerierte
|
|
// Geschwisterspalten. Getrennt ergaeben sie je Position einen eigenen Filter
|
|
// mit positionsabhaengigen Zahlen, die sich zudem nur UND-verknuepfen liessen.
|
|
it('merges numbered sibling columns of a multi-valued source field into one facet', () => {
|
|
const mitreSheet = makeSheet('Findings', [
|
|
['Title', 'MITRE ATT&CK_1', 'MITRE ATT&CK_2', 'MITRE ATT&CK_3'],
|
|
['A', 'Privilege Escalation', 'Credential Access', ''],
|
|
['B', 'Credential Access', 'Privilege Escalation', ''],
|
|
['C', 'Privilege Escalation', '', ''],
|
|
['D', 'Lateral Movement', 'Credential Access', 'Privilege Escalation']
|
|
], '', 'purple-knight.csv');
|
|
|
|
const facets = deriveDynamicFacets(mitreSheet, null, genericProfile, {});
|
|
const mitreFacets = facets.filter(facet => facet.label.toLowerCase().includes('mitre'));
|
|
expect(mitreFacets).toHaveLength(1);
|
|
expect(mitreFacets[0].label).toBe('MITRE ATT&CK');
|
|
expect(mitreFacets[0].columnIndexes).toEqual([1, 2, 3]);
|
|
// Jeder Datensatz zaehlt je Wert genau einmal, unabhaengig von der Position.
|
|
expect(mitreFacets[0].values.find(value => value.label === 'Privilege Escalation')?.count).toBe(4);
|
|
expect(mitreFacets[0].values.find(value => value.label === 'Credential Access')?.count).toBe(3);
|
|
});
|
|
|
|
it('matches a multi-valued field when any of its columns carries the selected value', () => {
|
|
const mitreSheet = makeSheet('Findings', [
|
|
['Title', 'MITRE ATT&CK_1', 'MITRE ATT&CK_2'],
|
|
['A', 'Privilege Escalation', 'Credential Access'],
|
|
['B', 'Credential Access', 'Privilege Escalation'],
|
|
['C', 'Lateral Movement', '']
|
|
], '', 'purple-knight.csv');
|
|
const facet = deriveDynamicFacets(mitreSheet, null, genericProfile, {})
|
|
.find(item => item.label === 'MITRE ATT&CK')!;
|
|
|
|
const records = filterAndSortRecords(mitreSheet, {
|
|
activeProfile: genericProfile,
|
|
search: '',
|
|
sortColumn: 'all',
|
|
sortDirection: 'desc',
|
|
statusFilter: 'all',
|
|
facetFilters: { [facet.id]: { values: ['privilege escalation'] } },
|
|
actionOnly: false,
|
|
reviews: {}
|
|
});
|
|
// A traegt den Wert in Spalte 1, B in Spalte 2 - beide muessen passen.
|
|
expect(records.map(record => record.title).sort()).toEqual(['A', 'B']);
|
|
});
|
|
|
|
// Ohne ausdrueckliche numerische Sortierung ordnet die Arbeitsansicht nach
|
|
// der kanonischen Severity. Das ersetzt das Sortieren nach werkzeugeigenen
|
|
// Punktespalten, die zwischen Werkzeugen nicht vergleichbar sind.
|
|
it('orders records by canonical severity when no numeric sort is active', () => {
|
|
const severitySheet = makeSheet('Findings', [
|
|
['Title', 'Severity'],
|
|
['A', 'Low'],
|
|
['B', 'Critical'],
|
|
['C', 'Informational'],
|
|
['D', 'HIGH'],
|
|
['E', 'Mittel']
|
|
], '', 'generic.csv');
|
|
|
|
const records = filterAndSortRecords(severitySheet, {
|
|
activeProfile: genericProfile,
|
|
search: '',
|
|
sortColumn: 'all',
|
|
sortDirection: 'desc',
|
|
statusFilter: 'all',
|
|
facetFilters: {},
|
|
actionOnly: false,
|
|
reviews: {}
|
|
});
|
|
// Gross-/Kleinschreibung und deutsche Schreibweise werden mit abgebildet.
|
|
expect(records.map(record => record.title)).toEqual(['B', 'D', 'E', 'A', 'C']);
|
|
});
|
|
|
|
it('adds review facets only when useful repeated workflow values exist', () => {
|
|
const records = sheet.analysis.records;
|
|
const reviews = {
|
|
[records[0].id]: { ...defaultReview(), status: 'Offen', owner: 'Team A' },
|
|
[records[1].id]: { ...defaultReview(), status: 'In Arbeit', owner: 'Team B' },
|
|
[records[2].id]: { ...defaultReview(), status: 'Offen', owner: 'Team A' }
|
|
};
|
|
const facets = deriveDynamicFacets(sheet, null, genericProfile, reviews);
|
|
expect(facets.find(facet => facet.id === 'review:status')?.values).toHaveLength(2);
|
|
expect(facets.find(facet => facet.id === 'review:owner')?.values).toHaveLength(2);
|
|
});
|
|
|
|
it('derives and applies a hierarchical CIS category filter', () => {
|
|
const cisSheet = makeSheet('assessmentResultTable', [
|
|
['Rule ID', 'Benchmark Item', 'Result'],
|
|
['17.2.4', '17.2.4 Ensure account management auditing', 'Fail'],
|
|
['17.8.1', '17.8.1 Ensure privilege use auditing', 'Fail'],
|
|
['18.5.5.1', '18.5.5.1 Ensure a network policy', 'Fail']
|
|
], '', 'cis-cat-synthetic.html', { toolId: 'cis-cat' });
|
|
const facets = deriveDynamicFacets(cisSheet, null, cisCatProfile, {});
|
|
const facet = facets.find(item => item.id === 'derived:cis-benchmark-category');
|
|
|
|
expect(facet?.label).toBe('CIS Category');
|
|
expect(facet?.values.map(value => value.label)).toEqual(expect.arrayContaining([
|
|
'CIS 17', 'CIS 17.2', 'CIS 17.8', 'CIS 18', 'CIS 18.5', 'CIS 18.5.5'
|
|
]));
|
|
|
|
const records = filterAndSortRecords(cisSheet, {
|
|
activeProfile: cisCatProfile,
|
|
search: '',
|
|
sortColumn: 'all',
|
|
sortDirection: 'desc',
|
|
statusFilter: 'all',
|
|
facetFilters: { [facet!.id]: { values: ['cis 17.2'] } },
|
|
actionOnly: false,
|
|
reviews: {}
|
|
});
|
|
expect(records.map(record => record.title)).toEqual(['17.2.4 Ensure account management auditing']);
|
|
});
|
|
});
|