426 lines
25 KiB
TypeScript
426 lines
25 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, sourceScanNavigationSheet } from '../../src/features/search-filter/dynamic-facets';
|
|
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
|
|
import { securityDomainCatalog } from '../../src/features/dashboard/security-domains';
|
|
import { cisBenchmarkSectionTitles } from '../../src/profiles/cis-cat/cis-benchmark-section-titles';
|
|
|
|
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.filter(facet => !facet.preserveEmptyValues).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('keeps the complete GPO filter set together and Source: Scan permanently last', () => {
|
|
const gpoSheet = makeSheet('Group Policy', [
|
|
['Title', 'Status', 'Category', 'Baseline', 'Comparison', 'Conflict', 'Description source', 'Diagnostics', 'Duplicates', 'Imported', 'Policy section', 'Policy type', 'Source: Scan'],
|
|
['A', 'Fail', 'GPOs', 'One', 'Mismatch', 'Yes', 'ADML', 'None', 'No', 'First', 'Registry', 'Policy', 'GPOs · Synthetic'],
|
|
['B', 'Pass', 'GPOs', 'Two', 'Match', 'No', 'Derived', 'Review', 'Yes', 'Second', 'Security', 'Preference', 'GPOs · Synthetic']
|
|
], '', 'synthetic', { toolId: 'group-policy' });
|
|
const facets = deriveDynamicFacets(gpoSheet, null, genericProfile, {});
|
|
expect(facets.map(facet => facet.label)).toEqual([
|
|
'Result Status', 'Category', 'Security Domain', 'Baseline', 'Comparison', 'Conflict', 'Description source', 'Diagnostics', 'Duplicates', 'Imported',
|
|
'Policy section', 'Policy type', 'Source: Scan'
|
|
]);
|
|
expect(facets.find(facet => facet.label === 'Security Domain')?.values)
|
|
.toEqual(securityDomainCatalog.map(domain => ({ value: domain.id, label: domain.label, count: domain.id === 'gpo' ? 2 : 0 })));
|
|
expect(facets.at(-1)?.values).toEqual([{ value: 'gpos · synthetic', label: 'GPOs · Synthetic', count: 2 }]);
|
|
});
|
|
|
|
it('uses the Client Info Security Domains as a real Work filter', () => {
|
|
const domainSheet = makeSheet('Findings', [
|
|
['Title', 'Category', 'Status'],
|
|
['Inactive account', 'Identity', 'Fail'],
|
|
['Firewall disabled', 'System hardening', 'Fail'],
|
|
['Audit logging disabled', 'Monitoring', 'Fail']
|
|
], '', 'synthetic.csv');
|
|
const facet = deriveDynamicFacets(domainSheet, null, genericProfile, {})
|
|
.find(item => item.id === 'derived:security-domain')!;
|
|
expect(facet.values.map(value => value.label)).toEqual(securityDomainCatalog.map(domain => domain.label));
|
|
expect(facet.values.filter(value => value.count > 0).map(value => value.value)).toEqual(['identity', 'hardening', 'recovery']);
|
|
const records = filterAndSortRecords(domainSheet, { activeProfile: genericProfile, search: '', sortColumn: 'all',
|
|
sortDirection: 'asc', statusFilter: 'all', facetFilters: { [facet.id]: { values: ['hardening'] } },
|
|
actionOnly: false, reviews: {} });
|
|
expect(records.map(record => record.title)).toEqual(['Firewall disabled']);
|
|
});
|
|
|
|
it('searches visible Work titles and fields without case sensitivity', () => {
|
|
const title = 'Presence of Admin accounts which do not have the flag "This account is sensitive and cannot be delegated": 17';
|
|
const searchSheet = makeSheet('PingCastle findings', [
|
|
['Title', 'Source Rule ID', 'Status', 'Source: Scan'],
|
|
[title, 'S-ADMIN-DELEGATION', 'Matched', 'PingCastle · Synthetic'],
|
|
['Unrelated synthetic finding', 'S-OTHER', 'Matched', 'PingCastle · Synthetic']
|
|
], '', 'synthetic-pingcastle.html', { toolId: 'pingcastle' });
|
|
const options = { activeProfile: genericProfile, sortColumn: 'all', sortDirection: 'asc' as const,
|
|
statusFilter: 'all', facetFilters: {}, actionOnly: false, reviews: {} };
|
|
|
|
expect(filterAndSortRecords(searchSheet, { ...options,
|
|
search: ' PRESENCE OF ADMIN ACCOUNTS WHICH DO NOT HAVE THE FLAG ' }).map(record => record.title)).toEqual([title]);
|
|
expect(filterAndSortRecords(searchSheet, { ...options,
|
|
search: 's-admin-delegation' }).map(record => record.title)).toEqual([title]);
|
|
});
|
|
|
|
it('classifies Maester ORCA and Entra suites instead of exposing them as Unclassified', () => {
|
|
const maesterSheet = makeSheet('Maester findings', [
|
|
['Title', 'Block', 'ID', 'Tag', 'Status', 'Source: Scan'],
|
|
['ORCA.104: High Confidence Phish action set to Quarantine message.', 'ORCA', 'ORCA.104',
|
|
'["ORCA","ORCA.104","EXO","Security","All"]', 'Failed', 'Maester · Synthetic'],
|
|
['Default Settings control is configured.', 'EIDSCA', 'EIDSCA.ST09',
|
|
'["EIDSCA","EIDSCA.ST09"]', 'Failed', 'Maester · Synthetic'],
|
|
['Conditional access policy is missing.', 'Maester', 'MT.1001',
|
|
'["Maester/Entra","Security"]', 'Failed', 'Maester · Synthetic']
|
|
], '', 'Maester.html', { toolId: 'maester' });
|
|
const facet = deriveDynamicFacets(maesterSheet, null, genericProfile, {})
|
|
.find(item => item.id === 'derived:security-domain')!;
|
|
|
|
expect(facet.values.find(value => value.value === 'cloud')?.count).toBe(2);
|
|
expect(facet.values.find(value => value.value === 'authentication')?.count).toBe(1);
|
|
expect(facet.values.some(value => value.value === 'unclassified')).toBe(false);
|
|
expect(filterAndSortRecords(maesterSheet, { activeProfile: genericProfile, search: '', sortColumn: 'all',
|
|
sortDirection: 'asc', statusFilter: 'all', facetFilters: { [facet.id]: { values: ['cloud'] } },
|
|
actionOnly: false, reviews: {} }).map(record => record.title)).toEqual([
|
|
'ORCA.104: High Confidence Phish action set to Quarantine message.',
|
|
'Default Settings control is configured.'
|
|
]);
|
|
});
|
|
|
|
it('classifies the retained PingCastle infrastructure and hardening findings from Work', () => {
|
|
const pingCastleSheet = makeSheet('PingCastle findings', [
|
|
['Title', 'Source Rule ID', 'Technical explanation', 'Status', 'Source: Scan'],
|
|
['Obsolete OS (Windows 7)', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Check if the Channel Binding is enabled for LDAPS', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Hardened Paths weakness', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Verify if there are restrictions for internet connectivity of script engines', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Check if OUs and Containers are protected from accidental deletion.', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Check if the mitigation for CVE-2021-42291 has been enabled', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Ensure that the rootDse Anonymous Binding is disabled', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Check if NTFRS is used to replicate SYSVOL', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Check if LDAPS is using TLS 1.0 or TLS 1.1.', '', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Ensure that the Print Spooler service cannot be abused to get the DC credentials',
|
|
'A-DC-Spooler', '', 'Matched', 'PingCastle · Synthetic'],
|
|
['Synthetic service configuration without a descriptive scanner title',
|
|
'CUSTOM-PRINT-SPOOLER-CONTROL', '', 'Matched', 'Synthetic Scanner'],
|
|
['Synthetic finding with narrative-only evidence', '',
|
|
'The Print Spooler service should be disabled on affected systems.', 'Matched', 'Synthetic Scanner']
|
|
], '', 'synthetic-pingcastle.html', { toolId: 'pingcastle' });
|
|
const facet = deriveDynamicFacets(pingCastleSheet, null, genericProfile, {})
|
|
.find(item => item.id === 'derived:security-domain')!;
|
|
|
|
expect(facet.values.find(value => value.value === 'hardening')?.count).toBe(7);
|
|
expect(facet.values.find(value => value.value === 'authentication')?.count).toBe(3);
|
|
expect(facet.values.find(value => value.value === 'infrastructure')?.count).toBe(2);
|
|
expect(facet.values.some(value => value.value === 'unclassified')).toBe(false);
|
|
expect(filterAndSortRecords(pingCastleSheet, { activeProfile: genericProfile, search: '', sortColumn: 'all',
|
|
sortDirection: 'asc', statusFilter: 'all', facetFilters: { [facet.id]: { values: ['hardening'] } },
|
|
actionOnly: false, reviews: {} }).map(record => record.title)).toContain(
|
|
'Ensure that the Print Spooler service cannot be abused to get the DC credentials');
|
|
});
|
|
|
|
it('uses Unclassified only when available fallback evidence would require guessing', () => {
|
|
const ambiguousSheet = makeSheet('Synthetic findings', [
|
|
['Title', 'Technical explanation', 'Status', 'Source: Scan'],
|
|
['Opaque finding', 'Review account passwords and backup monitoring together.', 'Failed', 'Synthetic Scanner']
|
|
], '', 'synthetic.csv', { toolId: 'generic' });
|
|
const facet = deriveDynamicFacets(ambiguousSheet, null, genericProfile, {})
|
|
.find(item => item.id === 'derived:security-domain')!;
|
|
|
|
expect(facet.values.find(value => value.value === 'unclassified')?.count).toBe(1);
|
|
});
|
|
|
|
it('provides a final Source: Scan control when a sheet has no explicit scan column', () => {
|
|
const fallbackSheet = makeSheet('Findings', [['Title', 'Severity'], ['A', 'High'], ['B', 'Low']], '', 'synthetic.csv');
|
|
const facets = deriveDynamicFacets(fallbackSheet, { scanTool: 'Synthetic Scanner' } as never, genericProfile, {});
|
|
expect(facets.at(-1)).toMatchObject({ label: 'Source: Scan', source: 'provenance' });
|
|
expect(facets.at(-1)?.values).toEqual([{ value: 'synthetic scanner', label: 'Synthetic Scanner', count: 2 }]);
|
|
});
|
|
|
|
it('offers every workspace scan from a dedicated GPO sheet and navigates to the aggregate', () => {
|
|
const aggregate = makeSheet('Client · All Records', [
|
|
['Title', 'Source: Scan'],
|
|
['Policy', 'GPOs · 2026-09-07'],
|
|
['Directory', 'PingCastle · 2026-09-06'],
|
|
['Cloud', 'Maester · 2026-09-05']
|
|
], '', 'synthetic', { isProjectSheet: true });
|
|
const gpo = makeSheet('GPOs', [
|
|
['Title', 'Status', 'Source: Scan'],
|
|
['Policy', 'Fail', 'GPOs · 2026-09-07']
|
|
], '', 'synthetic', { toolId: 'group-policy' });
|
|
const dataset = { sheets: [aggregate, gpo], scanTool: 'Multiple Tools' } as never;
|
|
|
|
const facets = deriveDynamicFacets(gpo, dataset, genericProfile, {});
|
|
const sourceScan = facets.at(-1);
|
|
expect(sourceScan).toMatchObject({ id: 'provenance:source-scan', label: 'Source: Scan' });
|
|
expect(sourceScan?.values.map(value => value.label)).toEqual([
|
|
'GPOs · 2026-09-07', 'Maester · 2026-09-05', 'PingCastle · 2026-09-06'
|
|
]);
|
|
expect(sourceScanNavigationSheet(dataset, gpo)?.id).toBe(aggregate.id);
|
|
expect(sourceScanNavigationSheet(dataset, aggregate)).toBeUndefined();
|
|
|
|
const recounted = recountFacetsAgainstActiveFilters({
|
|
facets, records: gpo.analysis.records, filters: {}, reviews: {}, statusFilter: 'all', matchesStatus: () => true
|
|
});
|
|
expect(recounted.at(-1)?.values).toEqual(sourceScan?.values);
|
|
});
|
|
|
|
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']);
|
|
});
|
|
|
|
describe('CIS category section titles', () => {
|
|
const scoreSection = (scanRunId: string, rows: string[][]) => ({
|
|
version: 1 as const, sectionKey: 'cis-cat-section-scores', label: 'Benchmark section scores', ordinal: 1, state: 'observed' as const,
|
|
tables: [{ name: 'Section scores', headers: ['Description', 'Percent'], rows }], source: { fileName: 'synthetic.html' }, attributes: {}, scanRunId
|
|
});
|
|
const cisSheet = (runs: string[]) => {
|
|
const created = makeSheet('assessmentResultTable', [
|
|
['Rule ID', 'Benchmark Item', 'Result'],
|
|
['17.2.4', '17.2.4 Ensure synthetic auditing', 'Fail'],
|
|
['2.3.10.1', '2.3.10.1 Ensure synthetic network access', 'Fail'],
|
|
['18.10.42.1', '18.10.42.1 Ensure synthetic antivirus', 'Fail']
|
|
], '', 'cis-cat-synthetic.html', { toolId: 'cis-cat' });
|
|
created.analysis.records.forEach((record, index) => { record.scanRunId = runs[index % runs.length]; });
|
|
return created;
|
|
};
|
|
const titles = (sections: ReturnType<typeof scoreSection>[]) => cisBenchmarkSectionTitles(sections);
|
|
const labels = (sheet: ReturnType<typeof cisSheet>, dataset: unknown) => deriveDynamicFacets(sheet, dataset as never, cisCatProfile, {})
|
|
.find(item => item.id === 'derived:cis-benchmark-category')?.values.map(value => value.label);
|
|
const syntheticRows = [['17 Synthetic Audit Policy', '50'], ['17.2 Synthetic Account Management', '50'], ['2 Synthetic Local Policies', '10'],
|
|
['2.3 Synthetic Security Options', '10'], ['2.3.10 Synthetic Network access', '10'], ['18 Synthetic Templates', '20'],
|
|
['18.10 Synthetic Components', '20'], ['18.10.42 Synthetic Antivirus', '20']];
|
|
|
|
it('names each number from its own run report and orders the numbers numerically', () => {
|
|
const sheet = cisSheet(['run-a']);
|
|
expect(labels(sheet, { sheets: [sheet], benchmarkSectionTitles: titles([scoreSection('run-a', syntheticRows)]) })).toEqual([
|
|
'CIS 2 · Synthetic Local Policies', 'CIS 2.3 · Synthetic Security Options', 'CIS 2.3.10 · Synthetic Network access',
|
|
'CIS 17 · Synthetic Audit Policy', 'CIS 17.2 · Synthetic Account Management',
|
|
'CIS 18 · Synthetic Templates', 'CIS 18.10 · Synthetic Components', 'CIS 18.10.42 · Synthetic Antivirus']);
|
|
// The selectable value stays the number, so filtering is unchanged.
|
|
const records = filterAndSortRecords(sheet, { activeProfile: cisCatProfile, search: '', sortColumn: 'all', sortDirection: 'desc',
|
|
statusFilter: 'all', facetFilters: { 'derived:cis-benchmark-category': { values: ['cis 17.2'] } }, actionOnly: false, reviews: {} });
|
|
expect(records.map(record => record.title)).toEqual(['17.2.4 Ensure synthetic auditing']);
|
|
});
|
|
|
|
it('keeps the bare number when runs disagree or a run lists no title', () => {
|
|
const sheet = makeSheet('assessmentResultTable', [
|
|
['Rule ID', 'Benchmark Item', 'Result'],
|
|
['18.10.42.1', '18.10.42.1 Ensure synthetic antivirus', 'Fail'],
|
|
['18.10.42.2', '18.10.42.2 Ensure synthetic scanning', 'Fail']
|
|
], '', 'cis-cat-synthetic.html', { toolId: 'cis-cat' });
|
|
sheet.analysis.records[0].scanRunId = 'run-a';
|
|
sheet.analysis.records[1].scanRunId = 'run-b';
|
|
const other = syntheticRows.map(([title, percent]) => [title.startsWith('18 ') ? '18 Different synthetic title' : title, percent]);
|
|
expect(labels(sheet, { sheets: [sheet], benchmarkSectionTitles: titles([scoreSection('run-a', syntheticRows), scoreSection('run-b', other)]) }))
|
|
.toEqual(['CIS 18', 'CIS 18.10 · Synthetic Components', 'CIS 18.10.42 · Synthetic Antivirus']);
|
|
expect(labels(cisSheet(['run-c']), { sheets: [], benchmarkSectionTitles: titles([scoreSection('run-a', syntheticRows)]) }))
|
|
.toEqual(['CIS 2', 'CIS 2.3', 'CIS 2.3.10', 'CIS 17', 'CIS 17.2', 'CIS 18', 'CIS 18.10', 'CIS 18.10.42']);
|
|
});
|
|
});
|
|
|
|
it('places the Dummy Windows hardening checks in System Hardening under the Work domain facet', () => {
|
|
const checks = [
|
|
["18.1.1.1", "Ensure 'Prevent enabling lock screen camera' is set to 'Enabled'"],
|
|
["18.1.1.2", "Ensure 'Prevent enabling lock screen slide show' is set to 'Enabled'"],
|
|
["18.4.2", "Ensure 'Configure SMB v1 client driver' is set to 'Enabled: Disable driver (recommended)'"],
|
|
["18.4.3", "Ensure 'Configure SMB v1 server' is set to 'Disabled'"],
|
|
["18.5.2", "Ensure 'MSS: (DisableIPSourceRouting IPv6) IP source routing protection level' is set to 'Enabled'"],
|
|
["18.5.3", "Ensure 'MSS: (DisableIPSourceRouting) IP source routing protection level' is set to 'Enabled'"],
|
|
["18.5.4", "Ensure 'MSS: (EnableICMPRedirect) Allow ICMP redirects to override OSPF generated routes' is set to 'Disabled'"],
|
|
["18.5.5", "Ensure 'MSS: (KeepAliveTime) How often keep-alive packets are sent in milliseconds' is set to 'Enabled'"],
|
|
["18.5.6", "Ensure 'MSS: (NoNameReleaseOnDemand) Allow the computer to ignore NetBIOS name release requests' is set to 'Enabled'"],
|
|
["18.5.7", "Ensure 'MSS: (PerformRouterDiscovery) Allow IRDP to detect and configure Default Gateway addresses' is set to 'Disabled'"],
|
|
["18.5.9", "Ensure 'MSS: (TcpMaxDataRetransmissions IPv6) How many times unacknowledged data is retransmitted' is set to 'Enabled'"],
|
|
["18.5.10", "Ensure 'MSS: (TcpMaxDataRetransmissions) How many times unacknowledged data is retransmitted' is set to 'Enabled'"],
|
|
["18.9.24.1", "Ensure 'Enumeration policy for external devices incompatible with Kernel DMA Protection' is set to 'Enabled'"],
|
|
] as const;
|
|
const cisSheet = makeSheet('assessmentResultTable', [
|
|
['Rule ID', 'Benchmark Item', 'Result'],
|
|
...checks.map(([id, title]) => [id, `${id} ${title}`, 'Fail']),
|
|
['18.99.1', '18.99.1 Opaque control', 'Fail']
|
|
], '', 'cis-cat-synthetic.html', { toolId: 'cis-cat' });
|
|
const facet = deriveDynamicFacets(cisSheet, null, cisCatProfile, {})
|
|
.find(item => item.id === 'derived:security-domain')!;
|
|
expect(facet.values.find(value => value.value === 'hardening')?.count).toBe(13);
|
|
expect(facet.values.find(value => value.value === 'unclassified')?.count).toBe(1);
|
|
const records = filterAndSortRecords(cisSheet, {
|
|
activeProfile: cisCatProfile, search: '', sortColumn: 'all', sortDirection: 'asc',
|
|
statusFilter: 'deviation', facetFilters: { [facet.id]: { values: ['hardening'] } },
|
|
actionOnly: false, reviews: {}
|
|
});
|
|
expect(records).toHaveLength(13);
|
|
expect(records.map(record => record.title)).toContain("18.4.3 Ensure 'Configure SMB v1 server' is set to 'Disabled'");
|
|
});
|
|
});
|