// @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 { importFile } from '../../src/importers/importer'; import { importProfiles } from '../../src/profiles/profile-catalog'; async function importPingCastle(content: string, fileName: string, type: string) { return importFile(new File([content], fileName, { type }), { profiles: importProfiles }); } describe('PingCastle native finding extraction', () => { 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
DomainDomain Functional LevelForest Functional LevelSchema VersionRecycle Bin EnabledNumber of DC
synthetic.example2016201688Yes2
', '

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

', '

User Information

Honey Pot

', '', '
NameCreation
Synthetic Honeypot2026-08-20

Account analysis

', '', '
Nb User AccountsNb Enabled
1210

Password Age Distribution

', '

Objects with a password which never expires

', '', '
NameLast logon
Synthetic UserNever
', '

Computer Information

Operating Systems

', '', '
NameOperating system
Synthetic ComputerSynthetic OS
', '

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
', '

GPO

Security settings

', '
GPO
Synthetic GPO
', '

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.importFiles[0].processedWith).toMatchObject({ profileId: 'pingcastle', profileVersion: '1.6.0', mappingVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1' }); expect(model.scanRuns[0].tool.version).toBe('3.4.0'); }); 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}

Value
Synthetic value
` ).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('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 = [ '
', '', "Informative rule
", '
', '

Synthetic informative rule

', '

Rule ID: S-Informative

', '

Description: Synthetic informative finding without risk points.

', '

Points: 0 points if present

', '
' ].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 Section

', '', '

Synthetic resolved paragraph without tenant identifiers.

', '
Name
Synthetic Entry
', '
', '', '

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 Section

', '', '

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

', '', '
DNReason
CN=Synthetic,DC=example,DC=testSynthetic reason
', '

Points: 10 points if present

' ].join(''); const targetGraph = [ '', '

Synthetic Graph

', '', '', '
Group
', 'Synthetic Administrators', '
', '', '', '
', '
User
Synthetic User
', 'Missing target', 'UnsafeExternal', '', '
Must not leak
Unrelated
' ].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 = [ '
', '', "+ 5 Point(s)
", '
', '

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'); }); });