241 lines
19 KiB
TypeScript
241 lines
19 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { DatasetSheet } from '../../src/domain/dataset';
|
|
import type { FindingRecord } from '../../src/domain/finding';
|
|
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { toolIdForRecord } from '../../src/importers/status-normalization';
|
|
import { powerShellDisplayLines } from '../../src/features/finding-reader/first-read';
|
|
import { cisSectionHeading } from '../../src/profiles/cis-cat/cis-benchmark-section-titles';
|
|
|
|
/** Synthetic scanner finding with the given retained fields. */
|
|
function record(fields: Array<[string, string]>): FindingRecord {
|
|
const values = fields.map(([label, value], index) => ({ label, value, index }));
|
|
return { id: 'synthetic', index: 0, title: 'Synthetic finding', fields: values, filled: values, meta: [] };
|
|
}
|
|
|
|
function sheet(rows: string[][]): DatasetSheet {
|
|
return makeSheet('Synthetic results', rows, '', 'synthetic.html');
|
|
}
|
|
|
|
function render(item: FindingRecord, toolId: string, linkedSheets: DatasetSheet[] = []) {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = renderToStaticMarkup(<FindingReader record={item} title={item.title} linkedSheets={linkedSheets} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId={toolId} onPrevious={() => {}} onNext={() => {}} onReview={() => {}}/>);
|
|
const values = (selector: string) => Object.fromEntries([...container.querySelectorAll(`${selector} .policy-problem__values > div`)]
|
|
.map(row => [row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent]));
|
|
return { container, values };
|
|
}
|
|
|
|
const headings = (container: HTMLElement) => [...container.querySelectorAll('.reader-summary > div > h4')].map(item => item.textContent);
|
|
|
|
describe('scanner first read', () => {
|
|
it('PingCastle: evidence and result rows as the problem, the advised solution as the next step, background below', () => {
|
|
const item = record([['Rule ID', 'S-Synthetic'], ['Title', 'Synthetic finding'], ['Status', 'Matched'], ['Evidence', 'Synthetic accounts found: 3'],
|
|
['Description', 'Synthetic description.'], ['Technical explanation', 'Synthetic explanation.'],
|
|
['Advised solution', 'Change the synthetic setting.\n\n$Synthetic = Get-Synthetic'], ['Documentation', 'Synthetic documentation']]);
|
|
const { container, values } = render(item, 'pingcastle', [sheet([['Account'], ['synthetic-a'], ['synthetic-b'], ['synthetic-c']])]);
|
|
const problem = container.querySelector('.finding-first-read.policy-problem')!;
|
|
expect(problem.getAttribute('data-outcome')).toBe(container.querySelector('.outcome-mark')?.getAttribute('data-outcome'));
|
|
expect(values('.finding-first-read')).toEqual({ Result: 'Synthetic accounts found: 3', Results: '3 rows in 1 table' });
|
|
const next = problem.nextElementSibling!;
|
|
expect(next.matches('section.policy-next-step')).toBe(true);
|
|
expect(next.textContent).toContain('Change the synthetic setting.');
|
|
// The remaining remediation text is complete, only behind a disclosure.
|
|
expect(next.querySelector('details summary')?.textContent).toBe('Full remediation');
|
|
expect(next.querySelector('details')?.textContent).toContain('$Synthetic = Get-Synthetic');
|
|
expect(headings(container)).toEqual(['Description', 'Technical explanation', 'Documentation']);
|
|
});
|
|
|
|
it('keeps a PingCastle PowerShell command complete and readable outside the prose', () => {
|
|
const command = 'Get-ADComputer -Filter {(Enabled -eq $true) -and (OperatingSystem -Like "*Windows 7*")} -Property Name,OperatingSystem | format-table Name,OperatingSystem -Wrap -Auto';
|
|
const advice = `Upgrade the workstations. You can check the OS with the following PowerShell command: ${command}`;
|
|
const item = record([['Advised solution', `${advice}\n\nConfirm the resulting inventory.`]]);
|
|
const { container } = render(item, 'pingcastle');
|
|
const step = container.querySelector('.finding-first-read.policy-next-step')!;
|
|
expect(step.querySelector('p')?.textContent).toBe('Upgrade the workstations. You can check the OS with the following PowerShell command:');
|
|
expect(step.querySelector('pre.finding-first-read__command code')?.textContent).toBe(
|
|
'Get-ADComputer -Filter {(Enabled -eq $true) -and (OperatingSystem -Like "*Windows 7*")} -Property Name,OperatingSystem |\n'
|
|
+ ' format-table Name,OperatingSystem -Wrap -Auto');
|
|
expect(step.querySelector('details')?.textContent).toContain('Confirm the resulting inventory.');
|
|
expect(item.fields[0].value).toBe(`${advice}\n\nConfirm the resulting inventory.`);
|
|
});
|
|
|
|
// Regression 2026-09-26: a script introduced by "the following script to ...:" and starting with a
|
|
// variable assignment stayed inside the prose instead of its own command block.
|
|
it('separates an introduced PowerShell script that starts with a variable assignment', () => {
|
|
const script = '$SyntheticSid = New-Object System.Security.Principal.SecurityIdentifier ([System.Security.Principal.WellKnownSidType]::AccountDomainUsersSid,(Get-ADDomain).DomainSID) '
|
|
+ 'Get-ADUser -Filter * -Properties PrimaryGroup | Where-Object { $_.PrimaryGroup -ne (Get-ADGroup -Filter {SID -eq $SyntheticSid}).DistinguishedName } | Select-Object UserPrincipalName,PrimaryGroup';
|
|
const lead = 'Restore the synthetic default. You can use the following script to list users with a synthetic primary group:';
|
|
const item = record([['Advised solution', `${lead} ${script}`]]);
|
|
const { container } = render(item, 'pingcastle');
|
|
const step = container.querySelector('.finding-first-read.policy-next-step')!;
|
|
expect(step.querySelector('p')?.textContent).toBe(lead);
|
|
const shown = step.querySelector('pre.finding-first-read__command code')?.textContent ?? '';
|
|
expect(shown.split('\n')).toEqual([
|
|
'$SyntheticSid = New-Object System.Security.Principal.SecurityIdentifier ([System.Security.Principal.WellKnownSidType]::AccountDomainUsersSid,(Get-ADDomain).DomainSID)',
|
|
'Get-ADUser -Filter * -Properties PrimaryGroup |',
|
|
' Where-Object { $_.PrimaryGroup -ne (Get-ADGroup -Filter {SID -eq $SyntheticSid}).DistinguishedName } |',
|
|
' Select-Object UserPrincipalName,PrimaryGroup']);
|
|
// Only whitespace changes; every other character is retained.
|
|
expect(shown.replace(/\s+/g, ' ')).toBe(script);
|
|
expect(item.fields[0].value).toBe(`${lead} ${script}`);
|
|
});
|
|
|
|
it('lays out a flattened command only at unambiguous structure boundaries', () => {
|
|
expect(powerShellDisplayLines('Get-Help Get-ADUser -Full')).toEqual(['Get-Help Get-ADUser -Full']);
|
|
expect(powerShellDisplayLines('Write-Output "a | b" | Out-Null')).toEqual(['Write-Output "a | b" |', ' Out-Null']);
|
|
expect(powerShellDisplayLines('Test-Path $a || Write-Output x')).toEqual(['Test-Path $a || Write-Output x']);
|
|
expect(powerShellDisplayLines('Get-A |\n Get-B')).toEqual(['Get-A |', ' Get-B']);
|
|
});
|
|
|
|
it('keeps prose with a colon but no command in the text', () => {
|
|
const advice = 'Review the script policy: re-run only signed scripts.';
|
|
const { container } = render(record([['Advised solution', advice]]), 'pingcastle');
|
|
expect(container.querySelector('pre.finding-first-read__command')).toBeNull();
|
|
expect(container.querySelector('.policy-next-step')?.textContent).toContain(advice);
|
|
});
|
|
|
|
it('Purple Knight: result message, affected count and likelihood as the problem, remediation as the next step', () => {
|
|
const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'],
|
|
['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'],
|
|
['Remediation', 'Remove the synthetic permission.']]);
|
|
const { container, values } = render(item, 'purple-knight');
|
|
expect(values('.finding-first-read')).toEqual({ Result: 'Found 2 synthetic objects.', Affected: '2', Impact: 'Synthetic likelihood.' });
|
|
expect(container.querySelector('.policy-next-step')?.textContent).toContain('Remove the synthetic permission.');
|
|
expect(container.querySelector('.policy-next-step details')).toBeNull();
|
|
expect(headings(container)).toEqual(['Description']);
|
|
});
|
|
|
|
it('CIS-CAT: the report criterion and evaluated row as expected and found, colored by the finding outcome', () => {
|
|
const item = record([['Benchmark Item', 'Synthetic finding'], ['Result', 'Fail'], ['Description', 'Synthetic description.'],
|
|
['Rationale', 'Synthetic rationale.'], ['Impact', 'Synthetic impact.'], ['Remediation', 'Set the synthetic value to 1.']]);
|
|
const evidence = sheet([['Item', 'Value'], ['Criterion: synthetic value', 'equals 1'], ['Synthetic value', '0']]);
|
|
evidence.analysis.records[1].highlighted = true;
|
|
const { container, values } = render(item, 'cis-cat', [evidence]);
|
|
expect(values('.finding-first-read')).toEqual({ Expected: 'Criterion: synthetic value · equals 1', Found: 'Synthetic value · 0', Impact: 'Synthetic impact.' });
|
|
expect(container.querySelector('.finding-first-read [data-state="deviating"] .policy-value-mark.is-deviating')?.textContent).toBe('Synthetic value · 0');
|
|
expect(headings(container)).toEqual(['Description', 'Rationale']);
|
|
});
|
|
|
|
it('Maester: counts only rows the source marks, keeps the test result out of the reader and links https documentation', () => {
|
|
const item = record([['ID', 'MT.9001'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['ResultDetail', 'Synthetic native test result'],
|
|
['Description', 'Synthetic description.'], ['HelpUrl', 'https://maester.dev/docs/tests/MT.9001']]);
|
|
const results = sheet([['Policy', 'Status'], ['Synthetic A', '❌ Disabled'], ['Synthetic B', '✅ Enabled'], ['Synthetic C', 'Report-only']]);
|
|
const { container, values } = render(item, 'maester', [results]);
|
|
expect(values('.finding-first-read')).toEqual({ Results: '1 of 3 rows marked failed · 1 marked passed' });
|
|
expect(container.querySelector('.reader-summary')?.textContent).not.toContain('Synthetic native test result');
|
|
const link = container.querySelector<HTMLAnchorElement>('.policy-next-step a')!;
|
|
expect(link.getAttribute('href')).toBe('https://maester.dev/docs/tests/MT.9001');
|
|
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
|
});
|
|
|
|
// Regression 2026-09-26: the mixed Work sheet carries the generic profile and names each record's scan in
|
|
// `Source: Scan`. Purple Knight runs are named by scope, which did not resolve to the profile, so the
|
|
// report showed the fields as plain sections instead of Problem and Next step.
|
|
it.each(['Purple Knight for Active Directory', 'Purple Knight for Entra ID', 'Purple Knight for Active Directory and Entra ID'])(
|
|
'Purple Knight in mixed Work (%s): opening the report shows Problem and Next step', runName => {
|
|
const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'],
|
|
['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'],
|
|
['Remediation', 'Remove the synthetic permission.'], ['Source: Scan', `${runName} · 2026-01-01T00:00:00.000Z`]]);
|
|
expect(toolIdForRecord(item, 'generic')).toBe('purple-knight');
|
|
const { container, values } = render(item, 'generic');
|
|
expect(values('.finding-first-read')).toEqual({ Result: 'Found 2 synthetic objects.', Affected: '2', Impact: 'Synthetic likelihood.' });
|
|
expect(container.querySelector('.policy-next-step')?.textContent).toContain('Remove the synthetic permission.');
|
|
expect(headings(container)).not.toContain('Remediation');
|
|
expect(headings(container)).not.toContain('Result message');
|
|
});
|
|
|
|
// Regression 2026-09-26: Purple Knight exports append the reference titles to the likelihood text and lose
|
|
// the paragraph breaks, so the Impact row ended in ">ReferencesNIST.SP.800-63-3...".
|
|
it('Purple Knight: impact ends before the appended references, which move to the references position', () => {
|
|
const likelihood = 'Synthetic weak passwords are easier to crack.The risk is higher for synthetic privileged accounts.'
|
|
+ '>ReferencesNIST.SP.800-63-3Synthetic weak password policy | ANSSI';
|
|
const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'],
|
|
['Likelihood of compromise', likelihood], ['Result message', 'Found 5 synthetic users.'], ['Number of results', '5'],
|
|
['Remediation', 'Apply the synthetic policy.'], ['Source: Scan', 'Purple Knight for Active Directory · 2026-01-01T00:00:00.000Z']]);
|
|
const { container } = render(item, 'generic');
|
|
const impact = [...container.querySelectorAll('.finding-first-read .policy-problem__values > div')]
|
|
.find(row => row.querySelector('dt')?.textContent === 'Impact')!.querySelector('dd')!;
|
|
expect(impact.textContent).not.toContain('References');
|
|
// The reader paragraph keeps line breaks (white-space: pre-wrap), so the restored break shows as a new paragraph.
|
|
expect(impact.textContent!.split('\n\n')).toEqual([
|
|
'Synthetic weak passwords are easier to crack.', 'The risk is higher for synthetic privileged accounts.']);
|
|
expect(headings(container)).toEqual(['Description', 'References']);
|
|
expect(container.querySelector('.reader-summary')!.textContent).toContain('NIST.SP.800-63-3Synthetic weak password policy | ANSSI');
|
|
expect(container.querySelector('.reader-summary')!.textContent!.split('Synthetic weak password policy').length).toBe(2);
|
|
expect(item.fields.find(field => field.label === 'Likelihood of compromise')?.value).toBe(likelihood);
|
|
});
|
|
|
|
it.each([
|
|
['a spaced heading', 'Synthetic impact text.References Synthetic reference title', 'Synthetic impact text.', 'Synthetic reference title'],
|
|
['a heading glued to the last word', 'Synthetic impact is resolvedReferencesSynthetic reference title', 'Synthetic impact is resolved', 'Synthetic reference title'],
|
|
['an ordinary mention', 'Open the References tab in the synthetic console.', 'Open the References tab in the synthetic console.', undefined]
|
|
])('Purple Knight impact with %s', (_case, likelihood, impact, references) => {
|
|
const item = record([['Name', 'Synthetic finding'], ['Likelihood of compromise', likelihood]]);
|
|
const { container } = render(item, 'purple-knight');
|
|
const row = [...container.querySelectorAll('.finding-first-read .policy-problem__values > div')].find(item => item.querySelector('dt')?.textContent === 'Impact');
|
|
expect(row?.querySelector('dd')?.textContent).toBe(impact);
|
|
expect(headings(container).includes('References')).toBe(Boolean(references));
|
|
if (references) expect(container.querySelector('.reader-summary')!.textContent).toContain(references);
|
|
});
|
|
|
|
it('Maester without a remediation field: the native Remediation action section becomes the next step, shown once', () => {
|
|
const description = 'Synthetic background paragraph.\n\n#### Remediation action:\n\nOpen the synthetic portal.\n\n#### Related links\n\n* Synthetic link';
|
|
const item = record([['ID', 'MT.9003'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', description],
|
|
['Source: Scan', 'Maester · 2026-01-01T00:00:00.000Z']]);
|
|
const { container } = render(item, 'generic');
|
|
const step = container.querySelector('.finding-first-read.policy-next-step')!;
|
|
expect(step.querySelector('p')?.textContent).toBe('Open the synthetic portal.');
|
|
expect(step.querySelector('details')?.textContent).toContain('Synthetic link');
|
|
const summary = container.querySelector('.reader-summary')!.textContent!;
|
|
expect(summary).toContain('Synthetic background paragraph.');
|
|
expect(summary.split('Open the synthetic portal.').length).toBe(2);
|
|
expect(summary).not.toContain('Remediation action');
|
|
expect(item.fields.find(field => field.label === 'Description')?.value).toBe(description);
|
|
});
|
|
|
|
it('Maester export with flattened line breaks still separates the native section at its heading', () => {
|
|
const item = record([['ID', 'MT.9004'], ['Title', 'Synthetic finding'], ['Status', 'Failed'],
|
|
['Description', 'Synthetic background. #### Remediation action: 1. Open the synthetic portal. 2. Save.']]);
|
|
const { container } = render(item, 'maester');
|
|
const step = container.querySelector('.policy-next-step')!.textContent!;
|
|
expect(step).toContain('Open the synthetic portal.');
|
|
expect(step).toContain('Save.');
|
|
const summary = container.querySelector('.reader-summary')!.textContent!;
|
|
expect(summary).toContain('Synthetic background.');
|
|
expect(summary.split('Open the synthetic portal.').length).toBe(2);
|
|
});
|
|
|
|
it('Maester without a native remediation section keeps the full description and shows no next step', () => {
|
|
const item = record([['ID', 'MT.9005'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', 'Only synthetic background.']]);
|
|
const { container } = render(item, 'maester');
|
|
expect(container.querySelector('.policy-next-step')).toBeNull();
|
|
expect(container.querySelector('.reader-summary')!.textContent).toContain('Only synthetic background.');
|
|
});
|
|
|
|
it('CIS-CAT report: each number badge names its report section on hover', () => {
|
|
const item: FindingRecord = { ...record([['Rule ID', '2.2.2'], ['Benchmark Item', '2.2.2 Ensure a synthetic user right'], ['Result', 'Fail']]),
|
|
benchmarkSectionTitles: new Map([['2', 'Synthetic Local Policies'], ['2.2', 'Synthetic User Rights Assignment']]) };
|
|
const badges = [...render(item, 'cis-cat').container.querySelectorAll('[data-derived-category="cis"]')];
|
|
expect(badges.map(badge => badge.textContent)).toEqual(['CIS 2', 'CIS 2.2']);
|
|
expect(badges.map(badge => badge.getAttribute('title'))).toEqual(['Synthetic Local Policies', 'Synthetic User Rights Assignment']);
|
|
});
|
|
|
|
it.each([
|
|
['2.2.2', '2.2.2 Ensure a synthetic user right', 'Synthetic User Rights Assignment: Ensure a synthetic user right'],
|
|
['9.9.9', '9.9.9 Ensure a synthetic unknown section', '9.9.9 Ensure a synthetic unknown section'],
|
|
['2.2.2', 'Ensure a heading without number', 'Ensure a heading without number']
|
|
])('CIS heading for rule %s: %s', (ruleId, heading, expected) => {
|
|
const titles = { benchmarkSectionTitles: new Map([['2.2', 'Synthetic User Rights Assignment']]) };
|
|
expect(cisSectionHeading(titles, ruleId, heading)).toBe(expected);
|
|
expect(cisSectionHeading({}, ruleId, heading)).toBe(heading);
|
|
});
|
|
|
|
it('never offers a non-https documentation link from imported content', () => {
|
|
const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['HelpUrl', 'javascript:alert(1)']]);
|
|
expect(render(item, 'maester').container.querySelector('.policy-next-step')).toBeNull();
|
|
});
|
|
});
|