// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import pingCastleHtml from '../../fixtures/pingcastle/healthcheck.synthetic.html?raw';
import pingCastleXml from '../../fixtures/pingcastle/healthcheck.synthetic.xml?raw';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { operatingSystemInventory, operatingSystemEolCount } from '../../src/features/dashboard/operating-system-inventory';
import { importFile } from '../../src/importers/importer';
import { partitionScannerTableRows } from '../../src/importers/scanner-table-notices';
import { importProfiles } from '../../src/profiles/profile-catalog';
import type { EnvironmentSnapshot } from '../../src/domain/environment-context';
async function importPingCastle(content: string, fileName: string, type: string) {
return importFile(new File([content], fileName, { type }), { profiles: importProfiles });
}
describe('PingCastle native finding extraction', () => {
it.each([100, 800, 1000])('retains every one of %i native account rows and separates the scanner limit notice without changing source rows', async count => {
const notice = 'Output limited to 100 items - go to the advanced menu before running the report or add "--no-enum-limit" to remove that limit';
const nativeRows = Array.from({ length: count }, (_, index) => `
| Synthetic-${index + 1} | 2026-01-01 |
`).join('');
const section = ``;
const dataset = await importPingCastle(pingCastleHtml.replace('', section + ''), 'ad_hc_large.synthetic.html', 'text/html');
const table = dataset.extraction!.reportSections!.find(item => item.sectionKey === 'sectionUserInformation')!.tables[0];
const original = JSON.stringify(table);
const projected = partitionScannerTableRows(table.rows);
expect(table.rows).toHaveLength(count + 1);
const retainedNotice = notice.replaceAll('"', '"');
expect(table.rows.at(-1)).toEqual([retainedNotice, retainedNotice]);
expect(projected.dataRows).toHaveLength(count);
expect(projected.dataRows.at(-1)!.row[0]).toBe(`Synthetic-${count}`);
expect(projected.notices).toEqual([retainedNotice]);
expect(JSON.stringify(table)).toBe(original);
const realAccount = [notice, '2026-01-01'];
expect(partitionScannerTableRows([realAccount]).dataRows[0].row).toBe(realAccount);
});
it('retains an explicit EOL OS total without altering native scores or findings', async () => {
const section = '';
const baseline = await importPingCastle(pingCastleHtml, 'ad_hc_eol.synthetic.html', 'text/html');
const dataset = await importPingCastle(pingCastleHtml.replace('', section + ''), 'ad_hc_eol.synthetic.html', 'text/html');
expect(dataset.extraction?.candidates).toEqual(baseline.extraction?.candidates);
expect(dataset.extraction?.environmentObservations?.filter(item => item.property.startsWith('risk.')))
.toEqual(baseline.extraction?.environmentObservations?.filter(item => item.property.startsWith('risk.')));
const eol = dataset.extraction?.environmentObservations?.find(item => item.label === 'EOL OS');
expect(eol).toMatchObject({ originalValue: '2', normalizedValue: 2, state: 'observed',
sectionKey: 'sectionComputerInformation', source: { rowIndex: 1, columnIndex: 0 } });
if (!eol) throw new Error('Expected retained EOL observation');
expect(operatingSystemEolCount({ version: 1, projectId: 'synthetic', scanRunIds: ['run'], generatedAt: '', sections: [],
coverage: { observed: 1, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
observations: [{ ...eol, id: 'synthetic-eol', scanRunId: 'run', importFileId: 'file', tool: { id: 'pingcastle', name: 'PingCastle' },
mappingVersion: '1', attributes: { ...eol.attributes,
...(eol.source.tableIndex === undefined ? {} : { sourceTableIndex: eol.source.tableIndex }) } }] } satisfies EnvironmentSnapshot)).toBe(2);
});
it('retains PingCastle base tables, cell modal links, and outside collapse links separately', async () => {
const nativeSection = `Admin Groups
Groups
Group summary.
Enterprise Administrators
| SamAccountName |
|---|
| Administrator |
Operator graph
Graph legend.
All privileged users.
| SamAccountName |
|---|
| Administrator |
`;
const dataset = await importPingCastle(pingCastleHtml.replace('', nativeSection + ''),
'ad_hc_links.synthetic.html', 'text/html');
const section = dataset.extraction?.reportSections?.find(item => item.sectionKey === 'sectionAdminGroups');
expect(section?.tables.map(table => [table.presentation, table.targetId])).toEqual([
['inline', undefined], ['modal', 'modalEnterprise-Administrators'], ['linked-inline', 'allprivileged']
]);
expect(section?.tables[0].cellLinks).toEqual([
{ rowIndex: 0, columnIndex: 0, targetId: 'modalEnterprise-Administrators' },
{ rowIndex: 1, columnIndex: 0, targetId: 'mcg-Operators' },
{ rowIndex: 2, columnIndex: 0, targetId: 'sectionExtra' }
]);
expect(section?.tables[1].title).toBe('Enterprise Administrators');
expect(section?.modalDetails).toEqual([{ targetId: 'mcg-Operators', title: 'Operator graph', prose: 'Operator graph Graph legend.', interactiveGraph: true }]);
expect(section?.detailLinks).toEqual([
{ targetId: 'allprivileged', label: 'All users in Admins groups', prose: 'All privileged users.' },
{ targetId: 'sectionExtra', label: 'Extra accounts' }
]);
expect(dataset.extraction?.reportSections?.find(item => item.sectionKey === 'sectionExtra')?.parentSectionKey).toBe('sectionAdminGroups');
expect(section?.prose).toContain('Group summary.');
expect(section?.prose).not.toContain('All privileged users.');
});
it('captures non-finding environment sections, typed facts and explicit absence in the canonical model', async () => {
const environmentSections = [
'Date: 2026-08-20 - Engine version: 3.4.0
',
'Domain Information
',
'
Directory',
'| Domain | Domain Functional Level | Forest Functional Level | Schema Version | Recycle Bin Enabled | Number of DC | ',
'
| synthetic.example | 2016 | 2016 | 88 | Yes | 2 |
',
'
Entra ID Configuration
No Entra ID configuration has been found in this domain.
',
'Public Key Infrastructure
Certificates
Certificate Authorities',
'| Certificate Authority |
|---|
',
'Infrastructure
Microsoft Entra Connect settings
',
'
| Service |
|---|
| Synthetic Infrastructure |
',
'Number of WEF configuration found: 0
Legacy LAPS installation date: Never Windows LAPS installation date: Never
',
'',
'',
'Admin Groups
Groups
| Group |
',
'| Synthetic Admin Group |
',
'Control Paths Analysis
Indirect links
| Path |
',
'| Synthetic Control Path |
',
'Anomalies
AdminSDHolder (detect temporary elevated accounts)
| Item |
',
'| Synthetic Anomaly |
',
'Password Policies
Password policies
| Policy |
',
'| Synthetic Password Policy |
',
'',
'Future Inventory
| Value |
',
'| Synthetic unknown value |
',
'Maturity
This section is reserved for customers who have purchased a license.
'
].join('');
const content = pingCastleHtml.replace('', environmentSections + '');
const dataset = await importPingCastle(content, 'ad_hc_environment.synthetic.html', 'text/html');
expect(dataset.extraction?.reportSections).toEqual(expect.arrayContaining([
expect.objectContaining({ sectionKey: 'sectionDomainInformation', state: 'observed' }),
expect.objectContaining({ sectionKey: 'sectionPKI', state: 'empty' }),
expect.objectContaining({ sectionKey: 'sectionMaturity', state: 'license-restricted' })
]));
expect(dataset.extraction?.environmentObservations).toEqual(expect.arrayContaining([
expect.objectContaining({ property: 'domain.fqdn', normalizedValue: 'synthetic.example', subject: expect.objectContaining({ type: 'domain-information' }) }),
expect.objectContaining({ property: 'domain.functional_level', normalizedValue: 2016 }),
expect.objectContaining({ property: 'forest.functional_level', normalizedValue: 2016 }),
expect.objectContaining({ property: 'domain.recycle_bin_enabled', normalizedValue: true }),
expect.objectContaining({ property: 'domain.controller_count', normalizedValue: 2 }),
expect.objectContaining({ property: 'entra.configuration_detected', state: 'explicitly-absent', subject: expect.objectContaining({ type: 'domain-information.entra-id-configuration' }) }),
expect.objectContaining({ property: 'logging.wef.configuration_count', normalizedValue: 0, subject: expect.objectContaining({ type: 'anomalies.windows-event-forwarding-wef' }) }),
expect.objectContaining({ property: 'laps.legacy.installation_date', originalValue: 'Never', state: 'explicitly-absent', subject: expect.objectContaining({ type: 'anomalies.laps' }) }),
expect.objectContaining({ property: 'report.engine_version', normalizedValue: '3.4.0' }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Honeypot', subject: expect.objectContaining({ type: 'user-information.honey-pot' }) }),
expect.objectContaining({ label: 'Nb User Accounts', normalizedValue: 12, subject: expect.objectContaining({ type: 'user-information.account-analysis' }) }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic User', subject: expect.objectContaining({ type: 'user-information.password-age-distribution' }) }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Computer', subject: expect.objectContaining({ type: 'computer-information.operating-systems' }) }),
expect.objectContaining({ originalValue: 'Synthetic Admin Group', subject: expect.objectContaining({ type: 'admin-groups.groups' }) }),
expect.objectContaining({ originalValue: 'Synthetic Control Path', subject: expect.objectContaining({ type: 'control-paths-analysis.indirect-links' }) }),
expect.objectContaining({ originalValue: 'Synthetic Infrastructure', subject: expect.objectContaining({ type: 'infrastructure.microsoft-entra-connect-settings' }) }),
expect.objectContaining({ originalValue: 'Synthetic Anomaly', subject: expect.objectContaining({ type: 'anomalies.adminsdholder-detect-temporary-elevated-accounts' }) }),
expect.objectContaining({ originalValue: 'Synthetic Password Policy', subject: expect.objectContaining({ type: 'password-policies.password-policies' }) }),
expect.objectContaining({ originalValue: 'Synthetic GPO', subject: expect.objectContaining({ type: 'gpo.security-settings' }) }),
expect.objectContaining({ originalValue: 'Synthetic unknown value', subject: expect.objectContaining({ type: 'pingcastle-table-row' }) })
]));
const preview = previewEntryFromDataset(dataset, {
sha256: 'environment'.padEnd(64, '0'), fileName: dataset.fileName, sizeBytes: content.length, lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch('environment-batch', [entry], '2026-08-20T12:00:00.000Z').model;
expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0);
expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0);
expect(model.environmentObservations).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Honeypot',
attributes: expect.objectContaining({ subsectionLabel: 'Honey Pot', sourceTableIndex: expect.any(Number) }) }),
expect.objectContaining({ label: 'Nb User Accounts', originalValue: '12',
attributes: expect.objectContaining({ subsectionLabel: 'Account analysis', sourceTableIndex: expect.any(Number) }) })
]));
expect(model.importFiles[0].processedWith).toMatchObject({
profileId: 'pingcastle', profileVersion: '1.7.0', mappingVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1'
});
expect(model.scanRuns[0].tool.version).toBe('3.4.0');
const inventory = operatingSystemInventory({ version: 1, projectId: model.project.id, generatedAt: '',
scanRunIds: model.scanRuns.map(run => run.id), sections: model.reportSections ?? [], observations: model.environmentObservations ?? [],
coverage: { observed: 0, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 } });
expect(inventory[2].entries).toEqual([expect.objectContaining({
name: 'Synthetic OS', count: 17, active: 12, inactive: 5, enabled: 14, disabled: 3
})]);
expect(inventory[2].total).toBe(17);
});
it('preserves every visible PingCastle environment subsection as a hierarchical subject type', async () => {
const definitions: Array<[string, string, Array<[string, string]>]> = [
['sectionDomainInformation', 'Domain Information', [['Entra ID Configuration', 'domain-information.entra-id-configuration']]],
['sectionUserInformation', 'User Information', [['Honey Pot', 'user-information.honey-pot'], ['Account analysis', 'user-information.account-analysis'], ['Password Age Distribution', 'user-information.password-age-distribution']]],
['sectionComputerInformation', 'Computer Information', [['Account analysis', 'computer-information.account-analysis'], ['Operating Systems', 'computer-information.operating-systems'], ['Domain controllers', 'computer-information.domain-controllers'], ['LAPS Analysis', 'computer-information.laps-analysis']]],
['sectionAdminGroups', 'Admin Groups', [['Groups', 'admin-groups.groups'], ['Last Logon Distribution', 'admin-groups.last-logon-distribution'], ['Delegations', 'admin-groups.delegations']]],
['sectionControlPathsAnalysis', 'Control Paths Analysis', [['Foreign domain involved', 'control-paths-analysis.foreign-domain-involved'], ['Indirect links', 'control-paths-analysis.indirect-links'], ['Admin groups', 'control-paths-analysis.admin-groups'], ['Critical Infrastructure', 'control-paths-analysis.critical-infrastructure']]],
['sectionTrustsdetails', 'Trusts Details', [['Discovered Domains', 'trusts-details.discovered-domains'], ['Reachable Domains', 'trusts-details.reachable-domains']]],
['sectionPKI', 'PKI', [['Certificates', 'pki.certificates'], ['Certificate Templates', 'pki.certificate-templates'], ['Domain Controller Certificate', 'pki.domain-controller-certificate']]],
['sectionInfrastructure', 'Infrastructure', [['Microsoft Entra Connect settings', 'infrastructure.microsoft-entra-connect-settings'], ['WSUS settings', 'infrastructure.wsus-settings'], ['Exchange settings', 'infrastructure.exchange-settings'], ['SCCM settings', 'infrastructure.sccm-settings'], ['Service Connection Points', 'infrastructure.service-connection-points'], ['Replacement of RC4 by AES in kerberos', 'infrastructure.replacement-of-rc4-by-aes-in-kerberos']]],
['sectionAnomalies', 'Anomalies', [['Backup', 'anomalies.backup'], ['LAPS', 'anomalies.laps'], ['Windows Event Forwarding (WEF)', 'anomalies.windows-event-forwarding-wef'], ['krbtgt (Used for Golden ticket attacks)', 'anomalies.krbtgt-used-for-golden-ticket-attacks'], ['AdminSDHolder (detect temporary elevated accounts)', 'anomalies.adminsdholder-detect-temporary-elevated-accounts'], ['Unix Passwords', 'anomalies.unix-passwords'], ['Java code reference', 'anomalies.java-code-reference'], ['Logon scripts', 'anomalies.logon-scripts'], ['Advanced', 'anomalies.advanced']]],
['sectionPasswordPolicies', 'Password Policies', [['Password policies', 'password-policies.password-policies'], ['Screensaver policies', 'password-policies.screensaver-policies']]],
['sectionGPO', 'GPO', [['Obfuscated Passwords', 'gpo.obfuscated-passwords'], ['Restricted Groups', 'gpo.restricted-groups'], ['Security settings', 'gpo.security-settings'], ['Audit settings', 'gpo.audit-settings'], ['Privileges', 'gpo.privileges'], ['Login', 'gpo.login'], ['GPO Login script', 'gpo.gpo-login-script'], ['GPO Deployed Files', 'gpo.gpo-deployed-files'], ['Folder Options', 'gpo.folder-options'], ['Microsoft Defender ASR (attack surface reduction)', 'gpo.microsoft-defender-asr-attack-surface-reduction'], ['Firewall configuration', 'gpo.firewall-configuration']]]
];
const sections = definitions.map(([sectionKey, sectionLabel, subsections]) =>
`${sectionLabel}
${subsections.map(([label]) =>
`
${label}
`
).join('')}
`
).join('');
const dataset = await importPingCastle(
pingCastleHtml.replace('', sections + ''),
'ad_hc_subsections.synthetic.html',
'text/html'
);
const subjectTypes = new Set((dataset.extraction?.environmentObservations ?? []).map(observation => observation.subject.type));
definitions.flatMap(([, , subsections]) => subsections).forEach(([, expectedType]) => {
expect(subjectTypes.has(expectedType), expectedType).toBe(true);
});
expect([...subjectTypes].some(type => /rule-details/.test(type))).toBe(false);
});
it('derives the HTML forest only from agreeing configuration partition DNs in the embedded graph data', async () => {
const graph = (selector: string, names: string[]) => `';
const withGraphs = (...blocks: string[]) => pingCastleHtml.replace('', blocks.join('') + '');
const forests = async (html: string) => {
const dataset = await importPingCastle(html, 'ad_hc_synthetic.example.html', 'text/html');
return {
observations: (dataset.extraction?.environmentObservations ?? []).filter(item => item.property === 'forest.name'),
diagnostics: (dataset.extraction?.diagnostics ?? []).filter(item => item.code === 'pingcastle-forest-root-conflict')
};
};
const agreeing = await forests(withGraphs(
graph('Data_Synthetic_A', ['CN=Services,CN=Configuration,DC=Forest,DC=Synthetic,DC=Example', 'CN=Domain Admins,CN=Users,DC=child,DC=forest,DC=synthetic,DC=example']),
graph('Data_Synthetic_B', ['CN=Configuration,DC=forest,DC=synthetic,DC=example'])
));
expect(agreeing.diagnostics).toEqual([]);
expect(agreeing.observations).toEqual([expect.objectContaining({
label: 'Forest', originalValue: 'forest.synthetic.example', state: 'observed',
source: expect.objectContaining({ objectPath: 'html/embedded-data/Data_Synthetic_A/nodes/0/name' }),
attributes: expect.objectContaining({ derivation: 'configuration-partition-dn', occurrenceCount: 2 })
})]);
const conflicting = await forests(withGraphs(graph('Data_Synthetic_A', [
'CN=Configuration,DC=one,DC=example', 'CN=Services,CN=Configuration,DC=two,DC=example'
])));
expect(conflicting.observations).toEqual([]);
expect(conflicting.diagnostics).toEqual([expect.objectContaining({ severity: 'warning', details: { rootCount: 2 } })]);
// No full DN, a truncated visible DN, a non-JSON script, unparsable JSON or a domain DN never yield a forest.
const absent = await forests(withGraphs(
'CN=Templates,CN=Configuration,DC=visible,DC=example
',
'',
'',
graph('Data_Synthetic_C', ['CN=Configuration', 'CN=Domain Admins,CN=Users,DC=domain,DC=example'])
));
expect(absent).toEqual({ observations: [], diagnostics: [] });
});
it('maps the top-level XML domain facts to the same environment properties the HTML import uses', async () => {
const xml = pingCastleXml.replace('synthetic.example', [
'3.5.1.33', '2026-07-22T10:18:58+02:00',
'synthetic.example', 'SYNTHETIC',
'forest.synthetic.example', '2026-05-27T10:24:53',
'S-1-5-21-1-2-3', '7',
'7', '88',
'false', '2'
].join(''));
const dataset = await importPingCastle(xml, 'ad_hc_synthetic.example.xml', 'application/xml');
const observations = dataset.extraction?.environmentObservations ?? [];
const fact = (property: string) => observations.filter(item => item.property === property);
expect(fact('domain.fqdn')).toEqual([expect.objectContaining({
label: 'Domain', originalValue: 'synthetic.example', normalizedValue: 'synthetic.example', state: 'observed',
subject: expect.objectContaining({ type: 'domain-information' }),
attributes: expect.objectContaining({ xmlPath: expect.stringMatching(/DomainFQDN\[\d+\]$/) })
})]);
expect(Object.fromEntries([
'domain.netbios_name', 'forest.name', 'domain.created_at', 'domain.functional_level', 'forest.functional_level',
'forest.schema_version', 'domain.recycle_bin_enabled', 'domain.controller_count', 'report.generated_at', 'report.engine_version'
].map(property => [property, fact(property).map(item => item.originalValue)]))).toEqual({
'domain.netbios_name': ['SYNTHETIC'], 'forest.name': ['forest.synthetic.example'],
'domain.created_at': ['2026-05-27T10:24:53'], 'domain.functional_level': ['7'], 'forest.functional_level': ['7'],
'forest.schema_version': ['88'], 'domain.recycle_bin_enabled': ['false'], 'domain.controller_count': ['2'],
'report.generated_at': ['2026-07-22T10:18:58+02:00'], 'report.engine_version': ['3.5.1.33']
});
// Unmapped leaves keep their exact path-based property; the domain SID gets no semantic name.
expect(observations.some(item => item.property.startsWith('pingcastle.xml.') && item.originalValue === 'S-1-5-21-1-2-3')).toBe(true);
expect(observations.some(item => item.property.startsWith('pingcastle.xml.') && item.originalValue === 'synthetic.example')).toBe(false);
// Nested leaves with a mapped name are not top-level facts.
const nested = await importPingCastle(pingCastleXml.replace('', 'OTHER'),
'ad_hc_synthetic.example.xml', 'application/xml');
expect(nested.extraction?.environmentObservations?.filter(item => item.property === 'domain.netbios_name')).toEqual([]);
});
it('maps the same matched rule identity from HTML and XML while preserving format-native provenance', async () => {
const datasets = await Promise.all([
importPingCastle(pingCastleHtml, 'ad_hc_synthetic.example.html', 'text/html'),
importPingCastle(pingCastleXml, 'ad_hc_synthetic.example.xml', 'application/xml')
]);
expect(datasets[0].intermediate?.diagnostics).not.toContainEqual(expect.objectContaining({
code: 'pingcastle-xml-results-unavailable'
}));
expect(datasets[1].intermediate?.diagnostics).toContainEqual(expect.objectContaining({
code: 'pingcastle-xml-results-unavailable',
severity: 'warning'
}));
const models = datasets.map((dataset, index) => {
expect(dataset.profileId).toBe('pingcastle');
expect(dataset.recognition?.source.selected.confidence.level).toBe('high');
expect(dataset.extraction?.extractor.id).toBe('pingcastle-healthcheck-source-extractor');
expect(dataset.mapping?.candidates).toHaveLength(1);
const candidate = dataset.mapping!.candidates[0];
expect(candidate.source.tableIndex).toBeUndefined();
expect(candidate.source.objectPath).toMatch(index === 0 ? /^html\/rules\// : /RiskRules/);
expect(candidate.fields.find(field => field.role === 'rule-id')?.decodedValue).toBe('S-Synthetic');
expect(candidate.fields.find(field => field.role === 'title')?.decodedValue)
.toBe('Synthetic inactive privileged account');
expect(candidate.fields.find(field => field.label === 'Domain Risk Level')?.decodedValue).toBe('20');
expect(candidate.fields.find(field => field.label === 'Category Score')?.decodedValue, index === 0 ? 'HTML' : 'XML').toBe('20');
// "Points" muss die tatsaechlich vergebene Punktzahl tragen (aus dem Card-Header-Badge
// "+ N Point(s)" bei HTML, aus bei XML), nicht das beschreibende Punkteschema.
expect(candidate.fields.find(field => field.label === 'Points')?.decodedValue).toBe('20');
if (index === 0) {
expect(candidate.fields.find(field => field.label === 'Points Scale')?.decodedValue)
.toBe('25 points if the occurence is greater than or equals than 25 then 20 points if present');
}
if (index === 1) {
expect(candidate.fields.find(field => field.label === 'Details')?.decodedValue).toBe([
'Synthetic detail without tenant identifiers.',
'Synthetic setting: Enabled',
'Synthetic values: Alpha, Beta'
].join('\n'));
}
const preview = previewEntryFromDataset(dataset, {
sha256: ('pingcastle-format-' + index).padEnd(64, '0').slice(0, 64),
fileName: dataset.fileName,
sizeBytes: index === 0 ? pingCastleHtml.length : pingCastleXml.length,
lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
expect(entry.recordCount).toBe(1);
expect(entry.mappingsToConfirm).toBe(0);
return createCanonicalImportBatch(
'pingcastle-format-' + index,
[entry],
'2026-07-22T12:00:00.000Z'
).model;
});
// Eine getroffene RiskRule ist eine erkannte Exposition und damit ein
// fachliches fail - Voraussetzung dafuer, dass der Befund als is_ioe gilt.
expect(models.map(model => ({
canonicalKey: model.findingDefinitions[0].canonicalKey,
title: model.findingDefinitions[0].title,
ruleId: model.findingOccurrences[0].sourceIdentity.canonicalRuleId,
status: model.findingOccurrences[0].status.normalizedValue,
severity: model.findingOccurrences[0].severity.normalizedValue
}))).toEqual([
{
canonicalKey: 'pingcastle:s-synthetic',
title: 'Synthetic inactive privileged account',
ruleId: 'S-Synthetic',
status: 'failed',
severity: 'medium'
},
{
canonicalKey: 'pingcastle:s-synthetic',
title: 'Synthetic inactive privileged account',
ruleId: 'S-Synthetic',
status: 'failed',
severity: 'medium'
}
]);
expect(models[0].findingOccurrences[0].sourcePosition.objectPath).toMatch(/^html\/rules\//);
expect(models[1].findingOccurrences[0].sourcePosition.objectPath).toMatch(/RiskRules/);
models.forEach(model => {
expect(model.findingOccurrences[0].severity.originalValue).toBe(20);
expect(model.findingOccurrences[0].severity.provenance.decisionSource).toBe('automatic');
expect(model.findingOccurrences[0].severity.provenance.confidence.reasons[0])
.toMatch(/pingcastle-severity-from-points@1\.0\.0/);
});
});
it('deduplicates repeated report presentations and ignores rule-shaped active HTML content', async () => {
const repeatedRule = [
'Duplicate rendering must not replace the first title
',
'Rule ID: S-Synthetic
',
'Description: Duplicate presentation.
',
'Points: 20
'
].join('');
const activeContent = '';
const content = pingCastleHtml.replace('', repeatedRule + activeContent + '');
const dataset = await importPingCastle(content, 'ad_hc_duplicate.synthetic.html', 'text/html');
expect(dataset.mapping?.candidates).toHaveLength(1);
expect(dataset.mapping?.candidates[0].fields.find(field => field.role === 'rule-id')?.decodedValue)
.toBe('S-Synthetic');
expect(dataset.mapping?.candidates[0].fields.find(field => field.role === 'title')?.decodedValue)
.toBe('Synthetic inactive privileged account');
expect(dataset.intermediate?.diagnostics).toContainEqual(expect.objectContaining({
code: 'html-active-content-removed'
}));
});
it('captures an evidence table PingCastle renders directly after "Details:" instead of dropping it', async () => {
const detailTableRule = [
'Synthetic detail table finding
',
'Rule ID: S-DetailTable
',
'Description: Synthetic finding with an inline evidence table.
',
'Details:The detail can be found in the Synthetic User List
',
'
| DN |
',
'| CN=Synthetic User,DC=example,DC=test |
',
'Points: 10
'
].join('');
const content = pingCastleHtml.replace('', detailTableRule + '');
const dataset = await importPingCastle(content, 'ad_hc_detail_table.synthetic.html', 'text/html');
const candidate = dataset.mapping?.candidates.find(
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-DetailTable'
);
expect(candidate?.fields.find(field => field.label === 'Details')?.decodedValue).toBe(
'The detail can be found in the Synthetic User List\nDN\nCN=Synthetic User,DC=example,DC=test'
);
});
it('reads 0 awarded points from the "Informative rule" card-header badge instead of leaving Points empty', async () => {
const informativeRule = [
''
].join('');
const content = pingCastleHtml.replace('', informativeRule + '');
const dataset = await importPingCastle(content, 'ad_hc_informative.synthetic.html', 'text/html');
const candidate = dataset.mapping?.candidates.find(
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-Informative'
);
expect(candidate?.fields.find(field => field.label === 'Points')?.decodedValue).toBe('0');
});
it('follows an internal "Details:" anchor link to its target subsection instead of leaving only the reference sentence', async () => {
const ruleWithLink = [
'Synthetic rule referencing another section
',
'Rule ID: S-AnchorLink
',
'Description: Synthetic finding whose detail lives in another report section.
',
'Details:The detail can be found in Synthetic Section
',
'Points: 10 points if present
'
].join('');
const targetSection = [
'',
'',
'',
'Synthetic resolved paragraph without tenant identifiers.
',
'
',
'
',
'',
'This must not leak into the resolved section above.
'
].join('');
const content = pingCastleHtml.replace('', ruleWithLink + targetSection + '');
const dataset = await importPingCastle(content, 'ad_hc_anchor_link.synthetic.html', 'text/html');
const candidate = dataset.mapping?.candidates.find(
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-AnchorLink'
);
const details = candidate?.fields.find(field => field.label === 'Details')?.decodedValue;
expect(details).toContain('Synthetic resolved paragraph without tenant identifiers.');
expect(details).toContain('Synthetic Entry');
expect(details).not.toContain('must not leak');
expect(details).not.toContain('href');
});
it('captures the embedded evidence table AND the linked target section for the same rule', async () => {
const ruleWithBoth = [
'Synthetic rule with embedded table and link
',
'Rule ID: S-Both
',
'Details:The detail can be found in Synthetic Section
',
'
| Domain controller |
',
'| SYNTHETIC-DC |
',
'Points: 10 points if present
'
].join('');
const targetSection = [
'',
'',
'',
'Synthetic target section paragraph.
',
'
| Name |
|---|
| Synthetic Target Entry |
',
'
',
''
].join('');
const content = pingCastleHtml.replace('', ruleWithBoth + targetSection + '');
const dataset = await importPingCastle(content, 'ad_hc_both.synthetic.html', 'text/html');
const candidate = dataset.mapping?.candidates.find(
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-Both'
);
const details = candidate?.fields.find(field => field.label === 'Details')?.decodedValue;
expect(details).toContain('SYNTHETIC-DC');
expect(details).toContain('Synthetic Target Entry');
const linked = candidate?.fields.find(field => field.key === 'linked-section');
const sections = (linked?.originalValue as { sections?: Array<{ sectionId: string }> })?.sections ?? [];
expect(sections.map(section => section.sectionId)).toEqual(['rule-details-S-Both', 'syntheticsection']);
});
it('follows modal and collapse targets recursively and keeps every reached table separate', async () => {
const ruleWithGraph = [
'Synthetic recursive result graph
',
'Rule ID: S-Graph
',
'Details:The detail can be found in Synthetic Graph
',
'| DN | Reason |
',
'| CN=Synthetic,DC=example,DC=test | Synthetic reason |
',
'Points: 10 points if present
'
].join('');
const targetGraph = [
'',
'Synthetic Graph
',
'',
'',
'',
'Synthetic Administrators
',
'
',
'',
'',
'Missing target',
'UnsafeExternal',
'',
''
].join('');
const content = pingCastleHtml.replace('', ruleWithGraph + targetGraph + '');
const dataset = await importPingCastle(content, 'ad_hc_graph.synthetic.html', 'text/html');
const candidate = dataset.mapping?.candidates.find(
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-Graph'
);
const linked = candidate?.fields.find(field => field.key === 'linked-section');
const sections = (linked?.originalValue as {
sections?: Array<{ sectionId: string; sectionLabel: string; headers: string[]; rows: string[][] }>
})?.sections ?? [];
expect(sections.map(section => section.sectionId)).toEqual([
'rule-details-S-Graph',
'graphsection',
'modalSynthetic',
'modalNested--table-1',
'modalNested--table-2',
'collapsedEvidence'
]);
expect(sections.map(section => section.sectionLabel)).toEqual([
'Details',
'Synthetic Graph',
'Synthetic Graph → Synthetic Administrators',
'Synthetic Graph → Synthetic Administrators → Nested Evidence · Tabelle 1',
'Synthetic Graph → Synthetic Administrators → Nested Evidence · Tabelle 2',
'Synthetic Graph → All synthetic users'
]);
expect(sections.flatMap(section => section.rows).flat()).not.toContain('Unrelated');
expect(sections.flatMap(section => section.headers)).not.toContain('Must not leak');
expect(dataset.intermediate?.diagnostics).toContainEqual(expect.objectContaining({
code: 'pingcastle-linked-section-unresolved',
details: { targetId: 'missingTarget', reason: 'unresolved' }
}));
});
it('prefers the PingCastle card-header rationale as finding title and keeps the check description', async () => {
const ruleWithHeader = [
'",
'
',
'Synthetic check description
',
'Rule ID: S-CardTitle
',
'Description: Synthetic finding with distinct header and check text.
',
'Points: 5 points if present
',
' '
].join('');
const content = pingCastleHtml.replace('', ruleWithHeader + '');
const dataset = await importPingCastle(content, 'ad_hc_cardtitle.synthetic.html', 'text/html');
const candidate = dataset.mapping?.candidates.find(
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-CardTitle'
);
expect(candidate?.fields.find(field => field.role === 'title')?.decodedValue).toBe('Synthetic rationale headline');
expect(candidate?.fields.find(field => field.label === 'Check title')?.decodedValue).toBe('Synthetic check description');
});
});