Assessor_2.0/tests/features/finding-reader.test.ts

505 lines
23 KiB
TypeScript

import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { makeSheet } from '../../src/importers/sheet-factory';
import { FindingReader, policyAnalyzerSearchKeys, selectFindingFields } from '../../src/features/finding-reader/FindingReader';
const record: FindingRecord = {
id: 'finding-1',
index: 0,
title: 'Non-admin users can register custom applications',
fields: [
{ index: 0, label: 'Name', value: 'Non-admin users can register custom applications' },
{ index: 1, label: 'Result', value: 'IOE Found' },
{ index: 2, label: 'Severity', value: 'High' },
{ index: 3, label: 'Result Message', value: 'Every user can register an application.' },
{ index: 4, label: 'Description', value: 'Description text' },
{ index: 5, label: 'ShortName', value: 'SI000160' },
{ index: 6, label: 'SI Version', value: '1.290.0' },
{ index: 7, label: 'Target', value: 'ME-ID' },
{ index: 8, label: 'Runtime', value: '00:00:14:20' },
{ index: 9, label: 'Duplicate description', value: 'Description text' },
{ index: 10, label: 'Internal note', value: 'Only shown as additional information' }
],
filled: [],
meta: []
};
describe('selectFindingFields', () => {
it('keeps technical metadata out of the finding text and in additional information', () => {
const selected = selectFindingFields(record, record.title);
expect(selected.summary.map(field => field.label)).toEqual(['Result Message', 'Description', 'Internal note']);
expect(selected.remainingFields.map(field => field.label)).toEqual([
'ShortName',
'SI Version',
'Target',
'Runtime'
]);
});
it('does not repeat title, badges, status, or values already displayed in the finding text', () => {
const selected = selectFindingFields(record, record.title);
const additionalLabels = selected.remainingFields.map(field => field.label);
expect(additionalLabels).not.toContain('Name');
expect(additionalLabels).not.toContain('Result');
expect(additionalLabels).not.toContain('Severity');
expect(additionalLabels).not.toContain('Duplicate description');
});
it('renders NVD Affected Configurations last in the normal Work finding summary', () => {
const enrichedRecord: FindingRecord = {
...record,
fields: [
...record.fields,
{ index: 11, label: 'NVD Affected Configurations', value: '| CPE | Vulnerable |\n| --- | --- |\n| synthetic | Yes |' },
{ index: 12, label: 'NVD Metrics', value: '| Score |\n| --- |\n| 4.3 |' }
]
};
const selected = selectFindingFields(enrichedRecord, record.title);
expect(selected.summary.at(-1)?.label).toBe('NVD Affected Configurations');
expect(selected.remainingFields.map(field => field.label)).not.toContain('NVD Affected Configurations');
const html = renderToStaticMarkup(createElement(FindingReader, {
record: enrichedRecord,
title: enrichedRecord.title,
linkedSheets: [],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html.indexOf('NVD Affected Configurations')).toBeGreaterThan(html.indexOf('NVD Metrics'));
});
it('keeps generated CIS-CAT Results references out of the finding text', () => {
const cisCatRecord: FindingRecord = {
...record,
fields: [
...record.fields,
{ index: 11, label: 'Results', value: 'source-table:3 "Assessment Evidence · Synthetic Item"' }
]
};
const selected = selectFindingFields(cisCatRecord, cisCatRecord.title);
expect(selected.summary.map(field => field.label)).not.toContain('Results');
expect(selected.remainingFields.map(field => field.label)).not.toContain('Results');
});
it('keeps legacy CIS-CAT XML in a closed optional Finding section without repeating the aggregate text', () => {
const cisCatRecord: FindingRecord = {
id: 'cis-cat-finding',
index: 0,
title: '1.1.1 Ensure the synthetic setting is enabled',
fields: [
{ index: 0, label: 'Benchmark Item', value: '1.1.1 Ensure the synthetic setting is enabled' },
{ index: 1, label: 'Result', value: 'Fail' },
{ index: 2, label: 'Description', value: 'Synthetic description.' },
{ index: 3, label: 'Rationale', value: 'Synthetic rationale.' },
{ index: 4, label: 'Results', value: 'source-table:3 "Assessment Evidence - Synthetic Item"' },
{
index: 5, label: 'CIS-CAT Details',
value: '## Description\n\nSynthetic description.\n\n## Rule Result XML\n\n```xml\n<result>fail</result>\n```\n\n## References\n\nSYN-000001'
}
],
filled: [],
meta: []
};
const selected = selectFindingFields(cisCatRecord, cisCatRecord.title, true, 'cis-cat');
expect(selected.derivedCategories).toEqual(['CIS 1', 'CIS 1.1']);
expect(selected.summary.map(field => field.label)).toEqual(['Description', 'Rationale']);
expect(selected.optionalFields).toMatchObject([{ label: 'Rule Result XML' }]);
expect(selected.optionalFields[0].value).toContain('<result>fail</result>');
expect(selected.remainingFields.map(field => field.label)).not.toContain('CIS-CAT Details');
const html = renderToStaticMarkup(createElement(FindingReader, {
record: cisCatRecord,
title: cisCatRecord.title,
linkedSheets: [],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
toolId: 'cis-cat',
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html).toContain('<details class="finding-optional-detail"><summary><svg class="triangle-icon triangle-icon-right"');
expect(html).toContain('Rule Result XML</summary>');
expect(html).toContain('data-derived-category="cis">CIS 1</span>');
expect(html).toContain('data-derived-category="cis">CIS 1.1</span>');
expect(html).not.toContain('CIS-CAT Details');
expect((html.match(/Synthetic description\./g) ?? [])).toHaveLength(1);
});
/**
* Nutzerkorrektur vom 2026-07-31: Das Rule Result XML des aktuellen Imports
* bleibt ein geschlossenes Detail im Finding und wandert nicht in das
* Results-Fenster. Die Rahmenwerk-Zuordnungen gehen umgekehrt dorthin und
* duerfen deshalb hier weder als Fliesstext noch unter den
* Zusatzinformationen erscheinen.
*/
it('keeps the current CIS-CAT XML expandable in the finding while control mappings move to the results window', () => {
const cisCatRecord: FindingRecord = {
id: 'cis-cat-current',
index: 0,
title: '1.1.1 Ensure the synthetic setting is enabled',
fields: [
{ index: 0, label: 'Benchmark Item', value: '1.1.1 Ensure the synthetic setting is enabled' },
{ index: 1, label: 'Result', value: 'Fail' },
{ index: 2, label: 'Description', value: 'Synthetic description.' },
{ index: 3, label: 'Control Mappings', value: '### CIS Controls V7.0\n\n- Control 1: Synthetic Inventory' },
{ index: 4, label: 'Results', value: 'source-table:3 "Assessment Evidence · Synthetic Item"' },
{ index: 5, label: 'Rule Result XML', value: '```xml\n<result>fail</result>\n```' }
],
filled: [],
meta: []
};
const selected = selectFindingFields(cisCatRecord, cisCatRecord.title, true, 'cis-cat');
expect(selected.optionalFields).toMatchObject([{ label: 'Rule Result XML' }]);
expect(selected.optionalFields[0].value).toBe('<result>fail</result>');
expect(selected.summary.map(field => field.label)).not.toContain('Control Mappings');
expect(selected.remainingFields.map(field => field.label)).not.toContain('Control Mappings');
const html = renderToStaticMarkup(createElement(FindingReader, {
record: cisCatRecord,
title: cisCatRecord.title,
linkedSheets: [],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
toolId: 'cis-cat',
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html).toContain('<details class="finding-optional-detail"><summary><svg class="triangle-icon triangle-icon-right"');
expect(html).toContain('Rule Result XML</summary>');
expect(html).not.toContain('Control 1: Synthetic Inventory');
});
it('zeigt im Purple-Knight-Reader Passed oder Failed statt des SI-Shortnames', () => {
const projected: FindingRecord = {
...record,
id: 'purple-knight-projected',
fields: [
{ index: 0, label: 'Name', value: 'Privileged accounts with a password that never expires' },
{ index: 1, label: 'Result', value: 'SI000067' },
{ index: 2, label: 'Status', value: 'IOE Found' },
{ index: 3, label: 'ShortName', value: 'SI000067' },
{ index: 4, label: 'Severity', value: 'High' }
]
};
const selected = selectFindingFields(projected, projected.title, false, 'purple-knight');
const html = renderToStaticMarkup(createElement(FindingReader, {
record: projected,
title: projected.title,
linkedSheets: [],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
toolId: 'purple-knight',
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(selected.statusField).toMatchObject({ label: 'Status', value: 'Failed' });
expect(html).toContain('<div class="finding-badges"><span class="outcome-mark" data-outcome="fail">Failed</span>');
expect(html).not.toContain('data-outcome="unknown">SI000067</span>');
});
it('shows GPO customer and baseline values as one readable comparison', () => {
const policyRecord: FindingRecord = {
id: 'gpo-comparison', index: 0, title: 'Synthetic policy setting', filled: [], meta: [],
fields: [
{ index: 0, label: 'Title', value: 'Synthetic policy setting' },
{ index: 1, label: 'Status', value: 'Failed' },
{ index: 2, label: 'Configured Value', value: '[{"kind":"registry-policy","valueName":"SyntheticSetting","data":{"kind":"number","value":12},"operation":"set-value","typeCode":4}]' },
{ index: 3, label: 'Baseline value', value: '{"kind":"number","value":14}' },
{ index: 4, label: 'Baseline', value: 'Synthetic Microsoft Baseline' }
]
};
const selected = selectFindingFields(policyRecord, policyRecord.title, false, 'group-policy');
const html = renderToStaticMarkup(createElement(FindingReader, {
record: policyRecord, title: policyRecord.title, linkedSheets: [], resultReferences: [], search: '',
position: 0, recordCount: 1, toolId: 'group-policy', onPrevious: () => undefined,
onNext: () => undefined, onReview: () => undefined
}));
expect(selected.policyComparisonFields.map(field => field.label)).toEqual(['Configured Value', 'Baseline value']);
expect(selected.summary.map(field => field.label)).not.toEqual(expect.arrayContaining(['Configured Value', 'Baseline value']));
expect(html).toContain('class="policy-value-comparison" data-outcome="fail"');
expect(html).toContain('Configured customer value');
expect(html).toContain('Expected baseline value');
expect(html).toContain('<div class="policy-search-key"><span>Policy Analyzer search key</span><div><code>SyntheticSetting</code></div></div>');
expect(policyAnalyzerSearchKeys(policyRecord, policyRecord.title, 'group-policy')).toEqual(['SyntheticSetting']);
expect(html).toContain('<h3>SyntheticSetting</h3>');
expect(html).toContain('<strong class="policy-value-emphasis">Data: 12</strong>');
expect(html).toContain('<strong class="policy-value-emphasis">Operation: set-value</strong>');
expect(html).toContain('\nType Code: 4</p>');
expect(html).toContain('<p class="policy-guidance-text">14</p>');
expect(html).not.toContain('Value Name: SyntheticSetting');
expect(html).not.toContain('&quot;kind&quot;');
});
it('derives a Policy Analyzer key from a technical GPO title without changing the title and omits preferences', () => {
const technical: FindingRecord = {
id: 'gpo-technical', index: 0,
title: 'Registry Values · MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\InactivityTimeoutSecs',
filled: [], meta: [], fields: [
{ index: 0, label: 'Title', value: 'Registry Values · MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\InactivityTimeoutSecs' },
{ index: 1, label: 'Status', value: 'Fail' },
{ index: 2, label: 'Policy type', value: 'security-template' }
]
};
expect(policyAnalyzerSearchKeys(technical, technical.title, 'group-policy')).toEqual(['InactivityTimeoutSecs']);
const preference: FindingRecord = {
...technical,
id: 'gpo-preference',
title: 'registry · HKEY_CURRENT_USER\\Software\\Microsoft\\Office\\16.0\\Outlook\\AutoDiscover\\ExcludeScpLookup',
fields: [
{ index: 0, label: 'Title', value: 'registry · HKEY_CURRENT_USER\\Software\\Microsoft\\Office\\16.0\\Outlook\\AutoDiscover\\ExcludeScpLookup' },
{ index: 1, label: 'Status', value: 'Not comparable' },
{ index: 2, label: 'Policy type', value: 'group-policy-preference' },
{ index: 3, label: 'Configured Value', value: '[{"kind":"registry-preference","key":"Software\\\\Microsoft\\\\Office\\\\16.0\\\\Outlook\\\\AutoDiscover","valueName":"ExcludeScpLookup"}]' }
]
};
expect(policyAnalyzerSearchKeys(preference, preference.title, 'group-policy')).toEqual(['AutoDiscover', 'ExcludeScpLookup']);
});
it('ordnet auch die alte PurpleKnight-Schreibweise Short Name den Zusatzinformationen zu', () => {
const legacyShortName: FindingRecord = {
...record,
fields: record.fields.map(field => field.label === 'ShortName' ? { ...field, label: 'Short Name' } : field)
};
const selected = selectFindingFields(legacyShortName, legacyShortName.title, false, 'purple-knight');
expect(selected.summary.map(field => field.label)).not.toContain('Short Name');
expect(selected.remainingFields.map(field => field.label)).toContain('Short Name');
});
it('verschiebt alte PurpleKnight-HTML-Details in Results und loest Framework-JSON auf', () => {
const legacy: FindingRecord = {
...record,
id: 'purple-knight-legacy-html',
fields: [
{ index: 0, label: 'Name', value: 'Synthetic legacy indicator' },
{ index: 1, label: 'Status', value: 'IOE Found' },
{ index: 2, label: 'ResultDetail', value: '[["Object: Synthetic","Ignored: False"]]' },
{ index: 3, label: 'Framework', value: JSON.stringify([
{ Name: 'MITRE ATT&CK', Tags: ['Discovery'] },
{ Name: 'MITRE D3FEND', Tags: ['Harden'] },
{ Name: 'ANSSI', Tags: ['vuln_synthetic'] }
]) },
{ index: 4, label: 'Grade', value: 'F' },
{ index: 5, label: 'Exposure Type', value: 'IOE' }
]
};
const selected = selectFindingFields(legacy, legacy.title, true, 'purple-knight');
expect(selected.mitre.map(field => [field.label, field.value])).toEqual([
['MITRE ATT&CK_1', 'Discovery'], ['MITRE D3FEND_1', 'Harden']
]);
expect(selected.remainingFields.map(field => [field.label, field.value])).toContainEqual(['ANSSI_1', 'vuln_synthetic']);
expect([...selected.summary, ...selected.remainingFields].map(field => field.label)).not.toEqual(
expect.arrayContaining(['ResultDetail', 'Framework', 'Grade', 'Exposure Type'])
);
});
it('shows PingCastle rule points and report scores as badges while leaving score formulas readable', () => {
const scoringRecord: FindingRecord = {
...record,
fields: [
...record.fields,
{ index: 11, label: 'Points', value: '20' },
{ index: 12, label: 'Category Score', value: '85' },
{ index: 13, label: 'Domain Risk Level', value: '100' },
{ index: 14, label: 'Scoring rule', value: '20 points if present' }
]
};
const selected = selectFindingFields(scoringRecord, scoringRecord.title);
expect(selected.badges.map(field => field.label)).toEqual([
'Severity',
'Points',
'Category Score',
'Domain Risk Level'
]);
expect(selected.summary.map(field => field.label)).toContain('Scoring rule');
});
it('hides PingCastle Details from reader text and Additional Information when structured Results exist', () => {
const pingCastleRecord: FindingRecord = {
id: 'pingcastle-detail-layout',
index: 0,
title: 'Synthetic PingCastle finding',
fields: [
{ index: 0, label: 'Rule ID', value: 'S-Synthetic' },
{ index: 1, label: 'Title', value: 'Synthetic PingCastle finding' },
{ index: 2, label: 'Status', value: 'Matched' },
{ index: 3, label: 'Description', value: 'Synthetic description.' },
{ index: 4, label: 'Technical explanation', value: 'Synthetic technical explanation.' },
{ index: 5, label: 'Advised solution', value: 'Synthetic advised solution.' },
{ index: 6, label: 'Details', value: 'Readable synthetic detail.\nSecond detail line.' },
{ index: 7, label: 'Points Scale', value: '10 points' },
{ index: 8, label: 'Verwandte Daten', value: 'file-synthetic::rule-details-S-Synthetic' }
],
filled: [],
meta: []
};
const selected = selectFindingFields(pingCastleRecord, pingCastleRecord.title, true, 'pingcastle');
expect(selected.summary.map(field => field.label)).toEqual([
'Description',
'Technical explanation',
'Advised solution'
]);
expect(selected.remainingFields.map(field => field.label)).toEqual(['Rule ID', 'Points Scale']);
const html = renderToStaticMarkup(createElement(FindingReader, {
record: pingCastleRecord,
title: pingCastleRecord.title,
linkedSheets: [makeSheet('Synthetic evidence', [['Field'], ['Value']], '', 'synthetic.html')],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
toolId: 'pingcastle',
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html).not.toContain('Readable synthetic detail.');
expect(html).not.toContain('<th>Details</th>');
expect(html).not.toContain('Verwandte Daten');
expect(html).not.toContain('file-synthetic::rule-details-S-Synthetic');
});
it('keeps PingCastle Details readable when no structured Results exist', () => {
const pingCastleRecord: FindingRecord = {
id: 'pingcastle-detail-fallback',
index: 0,
title: 'Synthetic PingCastle finding',
fields: [
{ index: 0, label: 'Title', value: 'Synthetic PingCastle finding' },
{ index: 1, label: 'Technical explanation', value: 'Synthetic technical explanation.' },
{ index: 2, label: 'Details', value: 'Readable fallback detail.' },
{ index: 3, label: 'Verwandte Daten', value: 'file-synthetic::rule-details-S-Synthetic' }
],
filled: [],
meta: []
};
const selected = selectFindingFields(pingCastleRecord, pingCastleRecord.title, false, 'pingcastle');
expect(selected.summary.map(field => field.label)).toEqual(['Technical explanation', 'Details']);
});
// Ergebnistabellen gehoeren ausschliesslich in das schwebende Fenster. Inline
// standen sie ein zweites Mal in der Lesespalte; ausserdem bleibt so
// ausgeschlossen, dass der Reader grosse Ergebnismengen mitrendert.
it('never renders linked result tables in the reader itself', () => {
const linkedSheet = makeSheet('Results', [
['Evidence'],
['EXPENSIVE-LINKED-RESULT-CONTENT']
], '', 'results.csv');
const html = renderToStaticMarkup(createElement(FindingReader, {
record,
title: record.title,
linkedSheets: [linkedSheet],
resultReferences: ['results.csv'],
search: '',
position: 0,
recordCount: 1,
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html).not.toContain('EXPENSIVE-LINKED-RESULT-CONTENT');
expect(html).not.toContain('Verknüpfte Results');
});
// Ohne geladene Ergebnisdatei ist die Results-Schaltflaeche abgeschaltet -
// dann bleibt der Hinweis die einzige Erklaerung fuer die Referenz.
it('uses the matching Result-Fensterfarbe for an open Results button', () => {
const html = renderToStaticMarkup(createElement(FindingReader, {
record,
title: record.title,
linkedSheets: [],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined,
onResults: () => undefined,
resultsOpen: true,
resultColor: '#059669'
}));
expect(html).toContain('results-btn is-open');
expect(html).toContain('--result-color:#059669');
});
it('keeps the hint when a reference exists but its result file is missing', () => {
const html = renderToStaticMarkup(createElement(FindingReader, {
record,
title: record.title,
linkedSheets: [],
resultReferences: ['results.csv'],
search: '',
position: 0,
recordCount: 1,
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html).toContain('Results (reference found)');
});
// Maester legt seine Testergebnismeldung selbst in die Spalte "Result".
// Anders als bei Purple Knight (kurzes Statuswort) ist das hier bereits der
// vollstaendige Befund - er gehoert deshalb in das Results-Fenster (siehe
// `resultMessageFor`/`FloatingResultsWindow`), nicht ein zweites Mal in den
// Finding-Fliesstext. "Result" bleibt im Reader deshalb fuer jedes Werkzeug
// einheitlich ausgeblendet.
const maesterRecord: FindingRecord = {
id: 'finding-2',
index: 0,
title: 'At least one Conditional Access policy is configured to secure security info registration only from a trusted location.',
fields: [
{ index: 0, label: 'ID', value: 'MT.1011' },
{ index: 1, label: 'Title', value: 'At least one Conditional Access policy is configured to secure security info registration only from a trusted location.' },
{ index: 2, label: 'Severity', value: 'High' },
{ index: 3, label: 'Status', value: 'Failed' },
{ index: 4, label: 'Category', value: 'Maester/Entra' },
{ index: 5, label: 'Description', value: 'Checks if the tenant has at least one conditional access policy securing security info registration.' },
{ index: 6, label: 'Result', value: 'No conditional access policy securing security info registration.' },
{ index: 7, label: 'Tags', value: 'Maester, CA, Security, MT.1011' }
],
filled: [],
meta: []
};
it('keeps the Maester "Result" field out of the finding text and additional information', () => {
const selected = selectFindingFields(maesterRecord, maesterRecord.title);
expect(selected.summary.map(field => field.label)).not.toContain('Result');
expect(selected.remainingFields.map(field => field.label)).not.toContain('Result');
});
});