391 lines
27 KiB
TypeScript
391 lines
27 KiB
TypeScript
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
|
// @vitest-environment jsdom
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useApp } from '../../src/app/AppProvider';
|
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
|
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG } from '../../src/assessment-context/context-catalog';
|
|
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
|
|
import { contextGapReport } from '../../src/assessment-context/context-gaps';
|
|
import type { StoredGpoPolicyAnalysis } from '../../src/domain/gpo-analysis-persistence';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import { CONTEXT_CLUSTER_FACET_ID } from '../../src/features/context-layer/context-cluster-facet';
|
|
import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context';
|
|
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
|
|
vi.mock('../../src/app/AppProvider', () => {
|
|
const useApp = vi.fn();
|
|
return { useApp, useOptionalApp: useApp };
|
|
});
|
|
|
|
const LSA = 'MACHINE\\System\\CurrentControlSet\\Control\\Lsa\\LmCompatibilityLevel';
|
|
const SMB = 'MACHINE\\System\\CurrentControlSet\\Services\\LanManServer\\Parameters\\RequireSecuritySignature';
|
|
|
|
function syntheticContext() {
|
|
const declared = [
|
|
{ id: 'obs-lm', backup: 'backup-a', key: LSA, raw: '2', identity: 'dummy-identity-lm', link: 'linked' },
|
|
{ id: 'obs-smb', backup: 'backup-b', key: SMB, raw: '0', identity: 'dummy-identity-smb', link: 'not-linked' }
|
|
];
|
|
const observations = declared.map(item => ({
|
|
resultProjectionId: `policy-observation:${item.id}`, sourceKind: 'policy-observation', sourceResultId: item.id,
|
|
policyScope: 'computer', policyState: 'configured', policyIdentityStatus: 'resolved', policyIdentityKey: item.identity,
|
|
policyValues: [{ kind: 'registry', typeCode: 4, rawData: item.raw }], policyBackupInstanceId: item.backup,
|
|
policyGpoId: `{DUMMY-${item.backup}}`, policyBackupLinkStatus: item.link
|
|
})) as unknown as UnifiedResultProjection[];
|
|
const analyses = [{ run: { id: 'dummy' }, resolutions: declared.map(item => ({
|
|
observationId: item.id,
|
|
resolution: { status: 'resolved', identity: { id: item.identity, target: { kind: 'security-template', section: 'Registry Values', key: item.key } } }
|
|
})) }] as unknown as StoredGpoPolicyAnalysis[];
|
|
return buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
|
|
observations, analyses, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
|
|
});
|
|
}
|
|
|
|
function gpoRecord(identity: string) {
|
|
return makeSheet('GPOs', [
|
|
['Title', 'Category', 'Status', 'Scope', 'Technical Identity', 'Source: Scan'],
|
|
['Dummy LAN Manager setting', 'GPOs', 'Deviation', 'computer', identity, 'GPOs']
|
|
], '', 'dummy.csv', { isProjectSheet: true, toolId: 'group-policy' }).analysis.records[0];
|
|
}
|
|
|
|
let host: HTMLDivElement;
|
|
let root: Root;
|
|
const dataset = { fileName: 'Dummy', sheets: [], scanTool: 'group-policy' };
|
|
|
|
async function renderReader(identity = 'dummy-identity-lm') {
|
|
await act(async () => {
|
|
root.render(<FindingReader record={gpoRecord(identity)} title="Dummy LAN Manager setting" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId="group-policy" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
const readerWithoutContext = () => {
|
|
const clone = host.querySelector('article')!.cloneNode(true) as HTMLElement;
|
|
clone.querySelector('.finding-context')?.remove();
|
|
clone.querySelector('[data-context-badges]')?.remove();
|
|
return clone.innerHTML;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
localStorage.clear();
|
|
resetEnginePreferencesForTests();
|
|
resetAssessmentContextRequestsForTests();
|
|
vi.mocked(useApp).mockReturnValue({
|
|
targetProjectId: 'dummy-project', activeAssessmentIds: ['dummy-assessment'], dataset, setFacetFilter: vi.fn()
|
|
} as unknown as ReturnType<typeof useApp>);
|
|
host = document.createElement('div');
|
|
document.body.append(host);
|
|
root = createRoot(host);
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => { root.unmount(); });
|
|
host.remove();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('Finding Reader context layer', () => {
|
|
it('makes no request and renders no context when Context Engine is disabled', async () => {
|
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
setEngineEnabled('context', false);
|
|
await renderReader();
|
|
expect(request).not.toHaveBeenCalled();
|
|
expect(host.querySelector('.finding-context')).toBeNull();
|
|
const technicalReader = readerWithoutContext();
|
|
await act(async () => { setEngineEnabled('context', true); });
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(readerWithoutContext()).toBe(technicalReader);
|
|
await act(async () => { setEngineEnabled('context', false); });
|
|
expect(host.querySelector('.finding-context')).toBeNull();
|
|
await renderReader('dummy-identity-smb');
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('preserves the standalone reader without AppProvider and requests no context without a scope', async () => {
|
|
const app = useApp();
|
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
vi.mocked(useApp).mockReturnValue(null as unknown as ReturnType<typeof useApp>);
|
|
await renderReader();
|
|
const standalone = readerWithoutContext();
|
|
expect(host.querySelector('article')).not.toBeNull();
|
|
expect(host.querySelector('.finding-context')).toBeNull();
|
|
expect(request).not.toHaveBeenCalled();
|
|
vi.mocked(useApp).mockReturnValue(app);
|
|
await renderReader();
|
|
expect(host.querySelector('.finding-context')).not.toBeNull();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(readerWithoutContext()).toBe(standalone);
|
|
});
|
|
|
|
it.each(['dark', 'light'])('starts collapsed and supports opening, closing, badge navigation and record changes in %s', async theme => {
|
|
document.documentElement.dataset.theme = theme;
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
await renderReader();
|
|
const section = host.querySelector<HTMLDetailsElement>('details.finding-context')!;
|
|
expect(section.open).toBe(false);
|
|
const summary = section.querySelector('summary')!;
|
|
await act(async () => { summary.click(); });
|
|
expect(section.open).toBe(true);
|
|
expect(section.textContent).toContain('Declared in GPO backup');
|
|
await act(async () => { summary.click(); });
|
|
expect(section.open).toBe(false);
|
|
await act(async () => { host.querySelector<HTMLButtonElement>('.context-cluster-badge')!.click(); });
|
|
expect(useApp().setFacetFilter).toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, { values: ['credential-relay-exposure'] });
|
|
expect(section.open).toBe(false);
|
|
await act(async () => { summary.click(); });
|
|
// A different finding starts with its own closed disclosure.
|
|
const next = { ...gpoRecord('dummy-identity-smb'), id: 'next-record' };
|
|
await act(async () => {
|
|
root.render(<FindingReader record={next} title="Synthetic next setting" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={1} recordCount={2} toolId="group-policy" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
|
|
});
|
|
expect(host.querySelector<HTMLDetailsElement>('details.finding-context')!.open).toBe(false);
|
|
delete document.documentElement.dataset.theme;
|
|
});
|
|
|
|
it.each(['off', 'on', null])('always loads context regardless of the obsolete saved preference %s', async preference => {
|
|
if (preference) localStorage.setItem('ASSESSOR.contextLayer', preference);
|
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
await renderReader();
|
|
expect(host.querySelector('details.finding-context')).not.toBeNull();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shows derived context and badge selection leaves the technical reader unchanged', async () => {
|
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
await renderReader();
|
|
const withoutLayer = readerWithoutContext();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(request).toHaveBeenCalledWith('dummy-project', ['dummy-assessment'], []);
|
|
const section = host.querySelector('.finding-context')!;
|
|
expect(section.querySelector('h4')?.textContent).toBe('Context');
|
|
expect(section.textContent).toContain('does not change the result above');
|
|
expect(section.textContent).toContain('Declared in GPO backup');
|
|
expect(section.textContent).toContain('Send NTLM response only (2)');
|
|
expect(section.textContent).toContain('LM or NTLMv1 responses are sent: Met');
|
|
expect(section.textContent).toMatch(/\bfavours?\b/);
|
|
expect(section.textContent).not.toMatch(/exploitable|confirmed attack|begünstigt/i);
|
|
expect(section.textContent).toContain('Credential Relay Exposure: Present');
|
|
expect(section.textContent).toContain('3 of 26 conditions met, 2 required');
|
|
expect(section.textContent).toContain('ATT&CK T1557.001');
|
|
const link = section.querySelector('a')!;
|
|
expect(link.getAttribute('href')).toMatch(/^https:\/\//);
|
|
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
|
expect(section.querySelector('[data-condition-state="satisfied"] .finding-context__glyph')?.textContent).toBe('●');
|
|
// User request 2026-10-01: cluster badges select the exact Work facet.
|
|
const badges = [...host.querySelectorAll('.reader-heading-actions [data-context-badges] .context-cluster-badge')];
|
|
expect(badges.map(badge => badge.textContent)).toEqual(['ContextCredential Relay Exposure']);
|
|
expect(badges[0].getAttribute('data-cluster-id')).toBe('credential-relay-exposure');
|
|
await act(async () => { (badges[0] as HTMLButtonElement).click(); });
|
|
expect(useApp().setFacetFilter).toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, { values: ['credential-relay-exposure'] });
|
|
expect(readerWithoutContext()).toBe(withoutLayer);
|
|
await renderReader();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('says plainly when a GPO setting is not in the catalog and reuses the loaded scope', async () => {
|
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
await renderReader('dummy-identity-unlisted');
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
expect(host.querySelector('.finding-context')?.textContent).toContain('This setting is not in the context catalog 1.9.0 yet');
|
|
expect(host.querySelector('[data-context-coverage]')?.textContent).toBe('In this timeslot, context covers 0 of 0 scanner findings and 2 GPO settings.');
|
|
await renderReader('dummy-identity-lm');
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
expect(host.querySelector('.finding-context')?.textContent).toContain('Met');
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shows context for a scanner finding with its observation next to the GPO declaration', async () => {
|
|
const base = syntheticContext();
|
|
const withScanner = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [{ resultProjectionId: 'scanner-finding:occ-pk', sourceKind: 'scanner-finding', sourceResultId: 'occ-pk',
|
|
sourceTool: 'purple-knight', sourceRuleId: 'SI000154', outcomeNormalized: 'fail', title: 'SMB Signing is not required on Domain Controllers' } as unknown as UnifiedResultProjection]
|
|
});
|
|
const combined = { ...base, observations: withScanner.observations, findings: withScanner.findings,
|
|
clusters: base.clusters.map(cluster => ({ ...cluster, conditions: cluster.conditions.map(item => item.conditionId === 'smb-server-signing-not-required'
|
|
? { ...item, satisfiedBy: [...item.satisfiedBy, withScanner.observations[0].id] } : item) })) };
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(combined);
|
|
const record = makeSheet('Purple Knight', [['Name', 'Status'], ['SMB Signing is not required on Domain Controllers', 'Failed']], '', 'pk.csv').analysis.records[0];
|
|
record.findingOccurrenceId = 'occ-pk';
|
|
await act(async () => {
|
|
root.render(<FindingReader record={record} title="SMB Signing is not required on Domain Controllers" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
});
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
const section = host.querySelector('.finding-context')!;
|
|
expect(section.textContent).toContain('SMB server does not require signing: Met');
|
|
expect(section.textContent).toContain('Observed by Purple Knight');
|
|
expect(section.textContent).toContain('SI000154: fail');
|
|
expect(section.textContent).toContain('Declared in GPO backup');
|
|
expect(section.textContent).toContain('Credential Relay Exposure');
|
|
expect([...section.querySelectorAll('a')].some(link => link.getAttribute('href') === 'https://www.semperis.com/purple-knight/')).toBe(true);
|
|
});
|
|
|
|
it('shows the ATT&CK tactic the scanners declare for a finding without an attack-path cluster', async () => {
|
|
const context = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: ['occ-quota', 'occ-other'].map((occurrence, index) => ({ resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding',
|
|
sourceResultId: occurrence, sourceTool: index ? 'pingcastle' : 'purple-knight', sourceRuleId: `R-${index}`, outcomeNormalized: 'fail', title: occurrence })) as never,
|
|
referenceRuns: [{ schemaVersion: 1, id: 'run', projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'scan', recognizerVersion: '1.0.0',
|
|
inputSha256: 'x', catalogHashes: [], createdAt: '2026-09-20T00:00:00Z', results: ['occ-quota', 'occ-other'].map(occurrenceId => ({ occurrenceId, importFileId: 'f', sourcePosition: {},
|
|
references: [{ framework: 'mitre-attack', id: 'TA0006', title: 'Credential Access', kind: 'tactic', tactics: [], status: 'active', evidence: 'declared',
|
|
matchedBy: 'id', catalogVersion: '19.2', catalogSha256: 'x', source: {} }] })) }] as never
|
|
});
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
|
|
const record = makeSheet('Purple Knight', [['Name', 'Status'], ['Unprivileged users can add computer accounts', 'Failed']], '', 'pk.csv').analysis.records[0];
|
|
record.findingOccurrenceId = 'occ-quota';
|
|
await act(async () => {
|
|
root.render(<FindingReader record={record} title="Unprivileged users can add computer accounts" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
});
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
const block = host.querySelector('[data-context-kind="declared-attack"]')!;
|
|
expect(block.textContent).toContain('TA0006 Credential Access');
|
|
expect(block.textContent).toContain('2 findings from 2 scanners declare this tactic');
|
|
expect(block.textContent).toContain('2 fail · 0 pass');
|
|
expect(block.textContent).toContain('not a proven attack chain');
|
|
// User decision 2026-09-30: a deviation without a catalogued path stays visible in the uncatalogued cluster.
|
|
expect(host.querySelector('.finding-context')?.textContent).toContain('Other Deviations (not yet catalogued)');
|
|
expect(host.querySelector('.finding-context')?.textContent).toContain('deviation not yet catalogued');
|
|
});
|
|
|
|
it('shows the D3FEND countermeasure the scanners declare, separately from ATT&CK', async () => {
|
|
const reference = (framework: string, id: string, title: string) => ({ framework, id, title, kind: 'technique', tactics: [], status: 'active',
|
|
evidence: 'declared', matchedBy: 'exact-title', catalogVersion: '1.0', catalogSha256: 'x', source: {} });
|
|
const context = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [['occ-weak', 'fail'], ['occ-strong', 'pass']].map(([occurrence, outcome]) => ({ resultProjectionId: `scanner-finding:${occurrence}`,
|
|
sourceKind: 'scanner-finding', sourceResultId: occurrence, sourceTool: 'purple-knight', sourceRuleId: `R-${occurrence}`, outcomeNormalized: outcome, title: occurrence })) as never,
|
|
referenceRuns: [{ schemaVersion: 1, id: 'run', projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'scan', recognizerVersion: '1.0.0',
|
|
inputSha256: 'x', catalogHashes: [], createdAt: '2026-09-20T00:00:00Z', results: ['occ-weak', 'occ-strong'].map(occurrenceId => ({ occurrenceId, importFileId: 'f', sourcePosition: {},
|
|
references: [reference('mitre-d3fend', 'D3-SPP', 'Strong Password Policy'), reference('mitre-attack', 'TA0007', 'Discovery')] })) }] as never
|
|
});
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
|
|
const record = makeSheet('Purple Knight', [['Name', 'Status'], ['Synthetic weak password finding', 'Failed']], '', 'pk.csv').analysis.records[0];
|
|
record.findingOccurrenceId = 'occ-weak';
|
|
await act(async () => {
|
|
root.render(<FindingReader record={record} title="Synthetic weak password finding" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
});
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
const d3fend = host.querySelector('[data-context-kind="declared-d3fend"]')!;
|
|
expect(d3fend.querySelector('h5')?.textContent).toBe('Declared D3FEND');
|
|
expect(d3fend.textContent).toContain('D3-SPP Strong Password Policy');
|
|
expect(d3fend.textContent).toContain('2 findings from 1 scanner declare this countermeasure');
|
|
expect(d3fend.textContent).toContain('1 fail · 1 pass');
|
|
expect(d3fend.textContent).toContain('Same countermeasure according to the scanners; not a proven common cause.');
|
|
const attack = host.querySelector('[data-context-kind="declared-attack"]')!;
|
|
expect(attack.textContent).toContain('TA0007 Discovery');
|
|
expect(attack.textContent).not.toContain('D3-SPP');
|
|
});
|
|
|
|
it('shows the CIS control area of a CIS-CAT finding from its own scan report', async () => {
|
|
const context = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [['occ-cis-1', '9.1.1', 'fail'], ['occ-cis-2', '9.1.2', 'fail'], ['occ-cis-3', '9.2.1', 'pass']].map(([occurrence, ruleId, outcome]) => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, scanRunId: 'run-cis',
|
|
sourceTool: 'cis-cat', sourceRuleId: ruleId, outcomeNormalized: outcome, title: `${ruleId} Ensure synthetic firewall setting` })) as never,
|
|
cisReportSections: [{ scanRunId: 'run-cis', sectionKey: 'cis-cat-section-scores', tables: [{ name: 'Scores', headers: ['Description', 'Pass', 'Fail'],
|
|
rows: [['9 Windows Firewall', '1', '2'], ['9.1 Domain Profile', '0', '7']] }] }]
|
|
});
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
|
|
const record = makeSheet('CIS-CAT', [['Title', 'Status'], ['9.1.1 Ensure synthetic firewall setting', 'Fail']], '', 'cis.csv').analysis.records[0];
|
|
record.findingOccurrenceId = 'occ-cis-1';
|
|
await act(async () => {
|
|
root.render(<FindingReader record={record} title="9.1.1 Ensure synthetic firewall setting" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId="cis-cat" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
});
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
const block = host.querySelector('[data-context-kind="cis-control-area"]')!;
|
|
expect([...block.querySelectorAll('[data-cis-section]')].map(item => item.getAttribute('data-cis-section'))).toEqual(['9', '9.1']);
|
|
expect(block.textContent).toContain('CIS 9 Windows Firewall');
|
|
expect(block.textContent).toContain('3 findings in this timeslot');
|
|
expect(block.textContent).toContain('CIS 9.1 Domain Profile');
|
|
expect(block.textContent).toContain('2 findings in this timeslot · 2 fail · 0 pass · scan report section score: 0 pass, 7 fail');
|
|
expect(block.textContent).toContain('Same benchmark section of this scan report; not a proven attack chain.');
|
|
});
|
|
|
|
it('has no global Context toolbar switch', () => {
|
|
const app = readFileSync(join(process.cwd(), 'src/app/App.tsx'), 'utf8');
|
|
expect(app).not.toContain('context-layer-btn');
|
|
expect(app).not.toContain('useContextLayerEnabled');
|
|
});
|
|
|
|
it('never touches reviews, scores or stored data from the layer code', () => {
|
|
const sources = ['src/features/context-layer/use-assessment-context.ts',
|
|
'src/features/finding-reader/FindingContext.tsx'].map(path => readFileSync(join(process.cwd(), path), 'utf8'));
|
|
for (const source of sources) {
|
|
expect(source).not.toMatch(/\b(?:setStatusFilter|setPathFindingIds|resetFilters|setActionOnly|updateReview|onReview|saveReview|recordRiskAcceptance|setSearch)\b/);
|
|
expect(source).not.toMatch(/method:\s*'(?:POST|PUT|PATCH|DELETE)'/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Finding Reader automatic context and gap review (2026-10-01)', () => {
|
|
function automaticContext() {
|
|
const evaluation = (identity: string, title: string) => ({
|
|
resultProjectionId: `policy-evaluation:${identity}`, sourceKind: 'policy-evaluation', sourceResultId: identity, policyScope: 'computer',
|
|
policyIdentityKey: identity, isDeviation: true, outcomeNormalized: 'fail', title
|
|
}) as unknown as UnifiedResultProjection;
|
|
const context = buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 5,
|
|
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
evaluations: [evaluation('dummy-identity-winrm', 'Allow Basic authentication (WinRM Service)'), evaluation('dummy-identity-lanman', 'Synthetic Lanman share hardening'),
|
|
evaluation('dummy-identity-lanman-2', 'Synthetic Lanman cache hardening')]
|
|
});
|
|
return { ...context, gapReport: contextGapReport(context) };
|
|
}
|
|
|
|
it('shows the recognized cluster with its confidence and opens the review list with a copyable approval', async () => {
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(automaticContext());
|
|
const writeText = vi.fn(async () => undefined);
|
|
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
|
|
await renderReader('dummy-identity-winrm');
|
|
// The recognized cluster is a badge and names its confidence as text.
|
|
const badges = [...host.querySelectorAll('[data-context-badges] [data-cluster-id]')];
|
|
expect(badges.map(item => item.getAttribute('data-cluster-id'))).toEqual(['lateral-movement-exposure']);
|
|
expect(badges[0].querySelector('[data-confidence]')?.textContent).toBe('recognized');
|
|
const section = host.querySelector<HTMLDetailsElement>('details.finding-context')!;
|
|
await act(async () => { section.querySelector('summary')!.click(); });
|
|
const recognized = section.querySelector('[data-context-recognized]')!;
|
|
expect(recognized.querySelector('h5')?.textContent).toBe('Recognized context');
|
|
expect(recognized.textContent).toContain('Lateral Movement Exposure');
|
|
expect(recognized.textContent).toContain('recognized · term in the rule text "WinRM"');
|
|
expect(recognized.textContent).toContain('It counts for the cluster but may be wrong');
|
|
|
|
const open = [...section.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Context gaps')!;
|
|
expect(section.textContent).toContain('2 deviations of this timeslot have no context yet');
|
|
await act(async () => { open.click(); });
|
|
const window = document.body.querySelector<HTMLElement>('[data-context-gaps]')!;
|
|
expect(window.querySelector('tr[data-tool="gpo"]')?.textContent).toContain('3');
|
|
const review = window.querySelector<HTMLTableRowElement>('tr[data-confidence="recognized"]')!;
|
|
expect(review.textContent).toContain('Allow Basic authentication (WinRM Service)');
|
|
const suggestion = window.querySelector<HTMLTableRowElement>('tr[data-suggestion="lanman"]')!;
|
|
expect(suggestion.textContent).toContain('Credential Relay Exposure');
|
|
const copy = [...window.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Copy approval')!;
|
|
expect(copy.disabled).toBe(true);
|
|
await act(async () => { review.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click(); });
|
|
await act(async () => { suggestion.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click(); });
|
|
await act(async () => { copy.click(); });
|
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('- Zuordnung bestaetigen: gpo "Allow Basic authentication (WinRM Service)" -> lateral-movement-exposure (recognized, title-term)'));
|
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('- Begriff: credential-relay-exposure <- "lanman" (deckt 2 Texte'));
|
|
expect(window.textContent).toContain('Copied.');
|
|
expect(window.textContent).toContain('Synthetic Lanman share hardening');
|
|
});
|
|
});
|