Assessor_2.0/tests/importers/pingcastle-source-extractor...

459 lines
32 KiB
TypeScript

// @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 } from '../../src/features/dashboard/operating-system-inventory';
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 = [
'<p>Date: 2026-08-20 - Engine version: 3.4.0</p>',
'<div id="sectionDomainInformation"><h1>Domain Information</h1>',
'<table><caption>Directory</caption><thead><tr>',
'<th>Domain</th><th>Domain Functional Level</th><th>Forest Functional Level</th><th>Schema Version</th><th>Recycle Bin Enabled</th><th>Number of DC</th>',
'</tr></thead><tbody><tr><td>synthetic.example</td><td>2016</td><td>2016</td><td>88</td><td>Yes</td><td>2</td></tr></tbody></table>',
'<h2>Entra ID Configuration</h2><p>No Entra ID configuration has been found in this domain.</p></div>',
'<div id="sectionPKI"><h1>Public Key Infrastructure</h1><h2>Certificates</h2><table><caption>Certificate Authorities</caption>',
'<thead><tr><th>Certificate Authority</th></tr></thead><tbody></tbody></table></div>',
'<div id="sectionInfrastructure"><h1>Infrastructure</h1><h2>Microsoft Entra Connect settings</h2>',
'<table><thead><tr><th>Service</th></tr></thead><tbody><tr><td>Synthetic Infrastructure</td></tr></tbody></table></div>',
'<div id="sectionAnomalyFacts"><p>Number of WEF configuration found: 0</p><p>Legacy LAPS installation date: Never Windows LAPS installation date: Never</p></div>',
'<div id="sectionUserInformation"><h1>User Information</h1><h2>Honey Pot</h2>',
'<table aria-label="Account list"><thead><tr><th>Name</th><th>Creation</th></tr></thead>',
'<tbody><tr><td>Synthetic Honeypot</td><td>2026-08-20</td></tr></tbody></table><h2>Account analysis</h2>',
'<table><thead><tr><th>Nb User Accounts</th><th>Nb Enabled</th></tr></thead>',
'<tbody><tr><td>12</td><td>10</td></tr></tbody></table><h2>Password Age Distribution</h2>',
'<div id="sectionNeverExpiresUser"><h3>Objects with a password which never expires</h3>',
'<table aria-label="Account list"><thead><tr><th>Name</th><th>Last logon</th></tr></thead>',
'<tbody><tr><td>Synthetic User</td><td>Never</td></tr></tbody></table></div></div>',
'<div id="sectionComputerInformation"><h1>Computer Information</h1><h2>Operating Systems</h2>',
'<table aria-label="Computer information list"><thead><tr><th>Name</th><th>Operating system</th><th>Nb OS</th><th>Nb Active</th><th>Nb Inactive</th><th>Nb Enabled</th><th>Nb Disabled</th></tr></thead>',
'<tbody><tr><td>Synthetic Computer</td><td>Synthetic OS</td><td>17</td><td>12</td><td>5</td><td>14</td><td>3</td></tr></tbody></table></div>',
'<div id="sectionAdminGroups"><h1>Admin Groups</h1><h2>Groups</h2><table><thead><tr><th>Group</th></tr></thead>',
'<tbody><tr><td>Synthetic Admin Group</td></tr></tbody></table></div>',
'<div id="sectionControlPathsAnalysis"><h1>Control Paths Analysis</h1><h2>Indirect links</h2><table><thead><tr><th>Path</th></tr></thead>',
'<tbody><tr><td>Synthetic Control Path</td></tr></tbody></table></div>',
'<div id="sectionAnomalies"><h1>Anomalies</h1><h2>AdminSDHolder (detect temporary elevated accounts)</h2><table><thead><tr><th>Item</th></tr></thead>',
'<tbody><tr><td>Synthetic Anomaly</td></tr></tbody></table></div>',
'<div id="sectionPasswordPolicies"><h1>Password Policies</h1><h2>Password policies</h2><table><thead><tr><th>Policy</th></tr></thead>',
'<tbody><tr><td>Synthetic Password Policy</td></tr></tbody></table></div>',
'<div id="sectionGPO"><h1>GPO</h1><h2>Security settings</h2><table><thead><tr><th>GPO</th></tr></thead>',
'<tbody><tr><td>Synthetic GPO</td></tr></tbody></table></div>',
'<div id="sectionFutureInventory"><h2>Future Inventory</h2><table><thead><tr><th>Value</th></tr></thead>',
'<tbody><tr><td>Synthetic unknown value</td></tr></tbody></table></div>',
'<div id="sectionMaturity"><h2>Maturity</h2><p>This section is reserved for customers who have purchased a license.</p></div>'
].join('');
const content = pingCastleHtml.replace('</main>', environmentSections + '</main>');
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.5.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]) =>
`<div id="${sectionKey}"><h1>${sectionLabel}</h1>${subsections.map(([label]) =>
`<h2>${label}</h2><table aria-label="${label} list"><thead><tr><th>Value</th></tr></thead><tbody><tr><td>Synthetic value</td></tr></tbody></table>`
).join('')}</div>`
).join('');
const dataset = await importPingCastle(
pingCastleHtml.replace('</main>', sections + '</main>'),
'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 <Points> 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 = [
'<article><h3>Duplicate rendering must not replace the first title</h3>',
'<p><strong>Rule ID:</strong> S-Synthetic</p>',
'<p><strong>Description:</strong> Duplicate presentation.</p>',
'<p><strong>Points:</strong> 20</p></article>'
].join('');
const activeContent = '<script><strong>Rule ID:</strong><p>ACTIVE-CONTENT</p></script>';
const content = pingCastleHtml.replace('</main>', repeatedRule + activeContent + '</main>');
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 = [
'<article><h3>Synthetic detail table finding</h3>',
'<p><strong>Rule ID:</strong> S-DetailTable</p>',
'<p><strong>Description:</strong> Synthetic finding with an inline evidence table.</p>',
'<strong>Details:</strong><p>The detail can be found in the Synthetic User List</p><div class="row">',
'<div class="col-md-12 table-responsive"><table class="table"><thead><tr><th>DN</th></tr></thead>',
'<tbody><tr><td>CN=Synthetic User,DC=example,DC=test</td></tr></tbody></table></div></div>',
'<p><strong>Points:</strong> 10</p></article>'
].join('');
const content = pingCastleHtml.replace('</main>', detailTableRule + '</main>');
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 = [
'<div class="card"><div class="card-header" id="headingrulesS-Informative">',
'<span class="card-title mb-0"><button class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#rulesS-Informative">Synthetic informative rule</button></span>',
"<i class=\"float-end\"><span class='float-end'>Informative rule</span></i></div>",
'<div id="rulesS-Informative" class="collapse"><div class="card-body risk-card"><article>',
'<h3>Synthetic informative rule</h3>',
'<p><strong>Rule ID:</strong> S-Informative</p>',
'<p><strong>Description:</strong> Synthetic informative finding without risk points.</p>',
'<p><strong>Points:</strong> 0 points if present</p>',
'</article></div></div></div>'
].join('');
const content = pingCastleHtml.replace('</main>', informativeRule + '</main>');
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 = [
'<article><h3>Synthetic rule referencing another section</h3>',
'<p><strong>Rule ID:</strong> S-AnchorLink</p>',
'<p><strong>Description:</strong> Synthetic finding whose detail lives in another report section.</p>',
'<strong>Details:</strong><p>The detail can be found in <a href="#syntheticsection">Synthetic Section</a></p>',
'<p><strong>Points:</strong> 10 points if present</p></article>'
].join('');
const targetSection = [
'<!-- SubSection Synthetic Section --><a name="syntheticsection"></a>',
'<div class="row"><div class="col-lg-12 mt-2"><h2 class="sub-section">Synthetic Section</h2></div></div>',
'<!-- SubSection Synthetic Section end -->',
'<div class="row"><div class="col-lg-12"><p>Synthetic resolved paragraph without tenant identifiers.</p>',
'<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Synthetic Entry</td></tr></tbody></table>',
'</div></div>',
'<!-- SubSection Next Section -->',
'<div class="row"><div class="col-lg-12"><p>This must not leak into the resolved section above.</p></div></div>'
].join('');
const content = pingCastleHtml.replace('</main>', ruleWithLink + targetSection + '</main>');
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 = [
'<article><h3>Synthetic rule with embedded table and link</h3>',
'<p><strong>Rule ID:</strong> S-Both</p>',
'<strong>Details:</strong><p>The detail can be found in <a href="#syntheticsection">Synthetic Section</a></p><div class="row">',
'<div class="col-md-12 table-responsive"><table class="table"><thead><tr><th>Domain controller</th></tr></thead>',
'<tbody><tr><td>SYNTHETIC-DC</td></tr></tbody></table></div></div>',
'<p><strong>Points:</strong> 10 points if present</p></article>'
].join('');
const targetSection = [
'<!-- SubSection Synthetic Section --><a name="syntheticsection"></a>',
'<div class="row"><div class="col-lg-12 mt-2"><h2 class="sub-section">Synthetic Section</h2></div></div>',
'<!-- SubSection Synthetic Section end -->',
'<div class="row"><div class="col-lg-12"><p>Synthetic target section paragraph.</p>',
'<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Synthetic Target Entry</td></tr></tbody></table>',
'</div></div>',
'<!-- SubSection Next Section -->'
].join('');
const content = pingCastleHtml.replace('</main>', ruleWithBoth + targetSection + '</main>');
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 = [
'<article><h3>Synthetic recursive result graph</h3>',
'<p><strong>Rule ID:</strong> S-Graph</p>',
'<strong>Details:</strong><p>The detail can be found in <a href="#graphsection">Synthetic Graph</a></p>',
'<table><thead><tr><th>DN</th><th>Reason</th></tr></thead><tbody>',
'<tr><td>CN=Synthetic,DC=example,DC=test</td><td>Synthetic reason</td></tr></tbody></table>',
'<p><strong>Points:</strong> 10 points if present</p></article>'
].join('');
const targetGraph = [
'<!-- SubSection Synthetic Graph --><a name="graphsection"></a>',
'<h2 class="sub-section">Synthetic Graph</h2>',
'<!-- SubSection Synthetic Graph end -->',
'<button data-bs-toggle="collapse" data-bs-target="#collapsedEvidence" aria-controls="collapsedEvidence">All synthetic users</button>',
'<table><thead><tr><th>Group</th></tr></thead><tbody><tr><td>',
'<a data-bs-toggle="modal" href="#modalSynthetic">Synthetic Administrators</a>',
'</td></tr></tbody></table>',
'<div class="modal" id="modalSynthetic"><h4 class="modal-title">Synthetic Administrators</h4>',
'<table><thead><tr><th>Account</th></tr></thead><tbody><tr><td>',
'<a data-bs-toggle="modal" href="#modalNested">Synthetic Account</a>',
'</td></tr></tbody></table></div>',
'<div class="modal" id="modalNested"><h4 class="modal-title">Nested Evidence</h4>',
'<a href="#modalSynthetic">Cycle back</a>',
'<table><thead><tr><th>Property</th></tr></thead><tbody><tr><td>First</td></tr></tbody></table>',
'<table><thead><tr><th>Property</th></tr></thead><tbody><tr><td>Second</td></tr></tbody></table>',
'</div>',
'<div id="collapsedEvidence" class="collapse"><table><thead><tr><th>User</th></tr></thead>',
'<tbody><tr><td>Synthetic User</td></tr></tbody></table></div>',
'<a href="#missingTarget">Missing target</a>',
'<a href="javascript:alert(1)">Unsafe</a><a href="https://example.invalid/">External</a>',
'<!-- SubSection Unrelated --><a name="unrelated"></a>',
'<!-- SubSection Unrelated end --><table><tr><th>Must not leak</th></tr><tr><td>Unrelated</td></tr></table>'
].join('');
const content = pingCastleHtml.replace('</main>', ruleWithGraph + targetGraph + '</main>');
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 = [
'<div class="card"><div class="card-header" id="headingrulesS-CardTitle">',
'<span class="card-title mb-0"><button class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#rulesS-CardTitle">Synthetic rationale headline</button></span>',
"<i class=\"float-end\"><span class='float-end'>+ 5 Point(s)</span></i></div>",
'<div id="rulesS-CardTitle" class="collapse"><div class="card-body risk-card"><article>',
'<h3>Synthetic check description</h3>',
'<p><strong>Rule ID:</strong> S-CardTitle</p>',
'<p><strong>Description:</strong> Synthetic finding with distinct header and check text.</p>',
'<p><strong>Points:</strong> 5 points if present</p>',
'</article></div></div></div>'
].join('');
const content = pingCastleHtml.replace('</main>', ruleWithHeader + '</main>');
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');
});
});