diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 387fc1e..a7652f9 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3979,6 +3979,12 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek ## 12. Änderungsprotokoll 2026-10-05 - Phase 7 / Management Report (Version 2.16.6): Zahlen an Client Info angeglichen (Report-Engine-Arbeit mit Core-Freigabe Evaluation, Core Tests und Governance; per Test und am Dummy-Client verifiziert). Nutzerentscheidung: Datengrundlage der Reports ist Raw Data der Work-Eintraege, die in Client Info angezeigten Zahlen sind verbindlich (neuer Abschnitt `Report numbers` in AGENTS.md). Behoben: Die Tabelle `Technische Domain-Auswertung` zaehlte Ergebniszeilen (Dummy: 998 bewertet/579 abweichend) statt Work-Eintraege; sie zaehlt jetzt mit demselben Klassifikator und denselben Ergebniszustaenden wie Client Info auf dem vollstaendigen Raw-Data-Blatt. Der Report-Datensatz liest dafuer dieselben aktuellen GPO-Bewertungen wie Client Info (`listCurrentPolicyEvaluationsForMetrics`). Dummy: 759/477 und je Domain 112/54/19/245/7/40 Abweichungen, identisch mit Client Info. Die vier Themen stehen in Summary, Uebersicht, Diagramm und Themenblaettern einheitlich nach Anzahl absteigend. Heatmap an der Basis behoben (Context Engine 1.11.1, Engine-Arbeit): GPO-Einstellungen mit katalogisierter Wertsemantik (Dummy: MinimumPasswordLength, PasswordComplexity, LockoutBadCount) waren ihrem Problemfeld zugeordnet, ihr ASSESSOR-Urteil `Fail` wurde aber nicht gelesen; die Heatmap zaehlte deshalb 474 statt 477. Die Engine fuehrt diese Urteile jetzt unveraendert als `declaredDeviations`; Heatmap, Client Info und Report zeigen 477 (Kennwortrichtlinie 22), `Ohne Problemkategorie` entfaellt. Synthetischer Regressionstest in `tests/assessment-context/context-engine.test.ts`. Zweite Pruefung am Dummy-Client (gleicher Release-Stand): Zuordnung in ASSESSOR vollstaendig (477 Abweichungen je genau ein Problemfeld und ein Thema, Domains gleich Client Info). Im Report korrigiert: nicht ausgefuehrt/Fehler/nicht anwendbar/unbekannt nach Work-Zustaenden (171/2/3/19 statt 170/2/3/0), Erhebungen nur Scan- und GPO-Erhebungslaeufe mit Timeslot-Datum der Quelldateien (5 im Timeslot 22.7.2026 statt 38 Verarbeitungsschritte), Risikoregister wie Client Info (1 erfasst, 0 aktuell gueltig statt 'dokumentiert'). Offen fuer Core: CIS-CAT `Manual` ist normalisiert `informational`, in Work aber `not run`. Versionsentscheidung: Patch 2.16.5 -> 2.16.6 (rueckwaertskompatible Korrekturen an Report- und Heatmap-Zahlen); Versionstests und beide Produktions-Builds bestanden. +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',