diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md
index d50f6e3..f4bc259 100644
--- a/docs/IMPLEMENTATION_PLAN.md
+++ b/docs/IMPLEMENTATION_PLAN.md
@@ -3404,6 +3404,12 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll
+2026-10-07 - Ingest / PingCastle-HTML-Gesamtstruktur aus Configuration-DN (Nutzerentscheidung Option 2; Core-Freigabe Ingest, Normalization, Core Tests; Produktversion 2.19.1): Der PingCastle-HTML-Bericht enthaelt kein Feld fuer den Gesamtstrukturnamen, nur die Gesamtstruktur-Funktionsebene. Vollstaendige DNs `CN=Configuration,DC=...` stehen nur in PingCastles eingebetteten Graphdaten (`';
+ 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'),
diff --git a/tests/persistence/unified-result.test.ts b/tests/persistence/unified-result.test.ts
index dce1dd5..e73848c 100644
--- a/tests/persistence/unified-result.test.ts
+++ b/tests/persistence/unified-result.test.ts
@@ -354,7 +354,7 @@ describe('Unified result read model', () => {
format: 'HTML',
fileName: 'synthetic-pingcastle.html',
processedWith: {
- parserId: 'html-report', parserVersion: '1.1.0', profileId: 'pingcastle', profileVersion: '1.5.0',
+ parserId: 'html-report', parserVersion: '1.1.0', profileId: 'pingcastle', profileVersion: '1.7.0',
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1'
}
@@ -402,7 +402,7 @@ describe('Unified result read model', () => {
resultProjectionId: 'environment-observation:environment-recycle-bin',
sourceKind: 'environment-observation',
sourceTool: 'pingcastle',
- sourceProfileVersion: '1.5.0',
+ sourceProfileVersion: '1.7.0',
mappingRuleVersion: '1.2.0+context-1.0.0',
processingStatus: 'current',
environmentProperty: 'domain.recycle_bin_enabled',
diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts
index 41a718d..8e4e3b9 100644
--- a/tests/server/assessor-http-server.test.ts
+++ b/tests/server/assessor-http-server.test.ts
@@ -59,7 +59,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({
- status: 'ok', application: 'ASSESSOR', productVersion: '2.19.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
+ status: 'ok', application: 'ASSESSOR', productVersion: '2.19.1', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
});
expect(health.headers.get('access-control-allow-origin')).toBeNull();
});