feat(pingcastle): Update PingCastle environment aggregates and configuration disclosures

This commit is contained in:
Binh 2026-09-15 13:26:28 +02:00
parent f651e57796
commit 48a1888b52
14 changed files with 374 additions and 19 deletions

View File

@ -1740,6 +1740,7 @@ Festlegung 2026-09-06 (Phase 13B/13C, ausdruecklicher Nutzerwunsch): Vor Rueckfr
Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene Docker-Compose-Bestand `assessor-2_0` mit der Datenbank `assessor`, dem Container `assessor-2_0-postgres-1` und dem persistenten Volume `assessor-2_0_assessor-postgres-data` ist der einzige kanonische lokale ASSESSOR-Datenbestand fuer diesen Workspace. Lokale Starts verwenden ausschliesslich `127.0.0.1:5432`. Die Compose-Konfiguration bindet dieses Volume deshalb namentlich als `external`; bei fehlendem Bestand oder Portkonflikt muss der Start fehlschlagen, statt eine weitere ASSESSOR-Datenbank oder einen Ersatzport anzulegen. Diese ausdrueckliche Nutzerfestlegung ersetzt die bisherige implizite, verzeichnisabhaengige Compose-Projektidentitaet. Der interne Browser-/Backend-/PostgreSQL-Zielaufbau bleibt unveraendert. Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene Docker-Compose-Bestand `assessor-2_0` mit der Datenbank `assessor`, dem Container `assessor-2_0-postgres-1` und dem persistenten Volume `assessor-2_0_assessor-postgres-data` ist der einzige kanonische lokale ASSESSOR-Datenbestand fuer diesen Workspace. Lokale Starts verwenden ausschliesslich `127.0.0.1:5432`. Die Compose-Konfiguration bindet dieses Volume deshalb namentlich als `external`; bei fehlendem Bestand oder Portkonflikt muss der Start fehlschlagen, statt eine weitere ASSESSOR-Datenbank oder einen Ersatzport anzulegen. Diese ausdrueckliche Nutzerfestlegung ersetzt die bisherige implizite, verzeichnisabhaengige Compose-Projektidentitaet. Der interne Browser-/Backend-/PostgreSQL-Zielaufbau bleibt unveraendert.
| Datum | Entscheidung | Begründung | | Datum | Entscheidung | Begründung |
| 2026-09-15 | Nicht-personenbezogene Umgebungsaggregate (Konten-, Inventar-, Passwortrichtlinien- und Konfigurationszaehlungen bzw. -booleans) ueberschreiten die Result-DB-Offenlegungsgrenze und den AI-Datenvertrag ausschliesslich ueber exakte Property-Allowlist-Regeln; identifizierende Namen und alle Zeitstempel bleiben ausgeschlossen. Die Werte werden aus bereits extrahierten Rohbeobachtungen abgeleitet und unter stabilen Property-Namen statt fragiler `tabelle.N`-Schluessel emittiert. | Client Info zeigte diese Aggregate bereits aus dem Roh-Snapshot, waehrend Result DB und AI sie fail-closed verwarfen. Stabile Ableitungen erlauben eine explizite, pruefbare Offenlegung ohne die fail-closed Grenze zu einer generischen Freigabe zu oeffnen; der Nutzer hat den Umfang (Konten inkl. Aufschluesselung, Inventar, Richtlinie, Konfiguration) und den Ausschluss von Namen/Zeitstempeln ausdruecklich bestimmt. |
| 2026-09-14 | AI Overview verwendet ausschliesslich das per Backend konfigurierte, unternehmensverwaltete LiteLLM ueber HTTPS; ein lokales Modell oder Browserzugriff auf Gateway/Key ist nicht vorgesehen. Der vollstaendige aktive Timeslot wird ueber den bestehenden Privacy-Safe-JSON-Vertrag uebertragen, grosse Eingaben werden ohne Record-Trunkierung hierarchisch zusammengefuehrt, und nur validierte Endergebnisse werden je Timeslot persistiert. | Der Nutzer hat Work-LiteLLM samt URL, Modellalias und API-Key bereit. Die bestehende Exportgrenze verhindert einen zweiten, weiter gefassten AI-Datenvertrag; serverseitige Secrets, explizite Ausloesung, lokale Validierung und append-only Historie halten Netz-, Kosten-, Datenschutz- und Auditgrenzen nachvollziehbar. | | 2026-09-14 | AI Overview verwendet ausschliesslich das per Backend konfigurierte, unternehmensverwaltete LiteLLM ueber HTTPS; ein lokales Modell oder Browserzugriff auf Gateway/Key ist nicht vorgesehen. Der vollstaendige aktive Timeslot wird ueber den bestehenden Privacy-Safe-JSON-Vertrag uebertragen, grosse Eingaben werden ohne Record-Trunkierung hierarchisch zusammengefuehrt, und nur validierte Endergebnisse werden je Timeslot persistiert. | Der Nutzer hat Work-LiteLLM samt URL, Modellalias und API-Key bereit. Die bestehende Exportgrenze verhindert einen zweiten, weiter gefassten AI-Datenvertrag; serverseitige Secrets, explizite Ausloesung, lokale Validierung und append-only Historie halten Netz-, Kosten-, Datenschutz- und Auditgrenzen nachvollziehbar. |
| 2026-09-14 | Die synthetische Mock-CVE-Datei wiederholt `Site` und `Sensor` je Zeile als ausdruecklich simulierte, ueber `Device ID` konsistent abgeleitete Fixture-Felder. Der Parser behandelt sie wie vorhandene optionale CVE-Kontextfelder; der Mehrdatei-Inventarjoin bleibt zusaetzlich bestehen. | Die CVE-Datei muss den Mockup auch als Einzelimport vollstaendig speisen. Die Kennzeichnung verhindert, dass die Zusatzspalten als behaupteter nativer Defender-Exportvertrag missverstanden werden. | | 2026-09-14 | Die synthetische Mock-CVE-Datei wiederholt `Site` und `Sensor` je Zeile als ausdruecklich simulierte, ueber `Device ID` konsistent abgeleitete Fixture-Felder. Der Parser behandelt sie wie vorhandene optionale CVE-Kontextfelder; der Mehrdatei-Inventarjoin bleibt zusaetzlich bestehen. | Die CVE-Datei muss den Mockup auch als Einzelimport vollstaendig speisen. Die Kennzeichnung verhindert, dass die Zusatzspalten als behaupteter nativer Defender-Exportvertrag missverstanden werden. |
| 2026-09-11 | Bis ein unveraenderter nativer Defender-for-IoT-Export vorliegt, ist das synthetische `Mock-Defender`-Paket die Entwicklungs- und Testquelle fuer den Defender-Importer. Actionable CVEs kommen ausschliesslich aus der strukturell validierten `cves.csv`; optionale Device-Inventare reichern nur ueber die explizite `Device ID` an. | Das bearbeitete Excel ist keine belastbare Rohquelle mehr. Der enge CSV-Vertrag ermoeglicht reproduzierbare Entwicklung ohne erfundene Site-, Sensor-, Beschreibungs- oder Scoresemantik und laesst sich spaeter anhand eines echten Exports versioniert validieren. | | 2026-09-11 | Bis ein unveraenderter nativer Defender-for-IoT-Export vorliegt, ist das synthetische `Mock-Defender`-Paket die Entwicklungs- und Testquelle fuer den Defender-Importer. Actionable CVEs kommen ausschliesslich aus der strukturell validierten `cves.csv`; optionale Device-Inventare reichern nur ueber die explizite `Device ID` an. | Das bearbeitete Excel ist keine belastbare Rohquelle mehr. Der enge CSV-Vertrag ermoeglicht reproduzierbare Entwicklung ohne erfundene Site-, Sensor-, Beschreibungs- oder Scoresemantik und laesst sich spaeter anhand eines echten Exports versioniert validieren. |
@ -2102,6 +2103,7 @@ Offen 2026-09-06 (Phase 13B): Die automatische Kontextableitung unterstuetzt der
Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projektnamen oder spontane Portausweichungen koennen mehrere auseinanderlaufende ASSESSOR-Datenbanken erzeugen und die App unbemerkt mit einem anderen Datenstand verbinden. Gegenmassnahme: `assessor-2_0` und `assessor-2_0_assessor-postgres-data` sind in Compose und `AGENTS.md` kanonisch gebunden; das Volume ist extern, Port `5432` verbindlich, alternative ASSESSOR-Datenbanken und Fallbackports sind untersagt. Vor Persistenzmigrationen oder destruktiven Eingriffen ist ein verifiziertes Backup erforderlich; fremde Docker-Bestaende bleiben unberuehrt. Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projektnamen oder spontane Portausweichungen koennen mehrere auseinanderlaufende ASSESSOR-Datenbanken erzeugen und die App unbemerkt mit einem anderen Datenstand verbinden. Gegenmassnahme: `assessor-2_0` und `assessor-2_0_assessor-postgres-data` sind in Compose und `AGENTS.md` kanonisch gebunden; das Volume ist extern, Port `5432` verbindlich, alternative ASSESSOR-Datenbanken und Fallbackports sind untersagt. Vor Persistenzmigrationen oder destruktiven Eingriffen ist ein verifiziertes Backup erforderlich; fremde Docker-Bestaende bleiben unberuehrt.
| Risiko | Gegenmaßnahme | | Risiko | Gegenmaßnahme |
| Eine kuenftige Erweiterung der Umgebungsaggregate koennte identifizierende Daten (Domaenen-/Forest-/Hostnamen, Zeitstempel) versehentlich in Result DB oder AI Overview aufnehmen. | Offenlegung bleibt fail-closed mit exakter Tool-/Property-Allowlist und engen Wertvalidatoren; die Ableitung erzeugt ausschliesslich Zaehl-/Booleanwerte unter neutralem `active-directory-environment`-Subject ohne Quellidentitaet. Regressionstests pruefen gleichzeitig die Aufnahme der Aggregate und den fortgesetzten Ausschluss von `domain.fqdn`; neue offenzulegende Properties erfordern eine explizite Regel plus `SAFE_ENVIRONMENT_PROPERTIES`-Eintrag. |
| Eine spaetere Kartenrefaktorierung kann wieder alle Client-Info-Karten standardmaessig oeffnen und damit die vereinbarte kompakte Startansicht verlieren. | Den Startzustand an jeder konkreten Karteninstanz explizit setzen und statisch pruefen: neun `initiallyExpanded={false}` sowie genau ein nicht minimierter `Assessment Status`; die gemeinsame Komponente bleibt fuer gezielte Wiederverwendung konfigurierbar. | | Eine spaetere Kartenrefaktorierung kann wieder alle Client-Info-Karten standardmaessig oeffnen und damit die vereinbarte kompakte Startansicht verlieren. | Den Startzustand an jeder konkreten Karteninstanz explizit setzen und statisch pruefen: neun `initiallyExpanded={false}` sowie genau ein nicht minimierter `Assessment Status`; die gemeinsame Komponente bleibt fuer gezielte Wiederverwendung konfigurierbar. |
| Eine minimierte Client-Info-Karte kann durch intrinsische Flexbreite schmaler als ihre Spalte werden; werkzeugspezifische Provenienz kann ausserdem erneut in einen fachlichen Verzeichnisueberblick geraten. | Collapsed-Karten explizit auf `width: 100%` setzen und diese Geometrie regressionspruefen. `directoryFacts` auf fachliche Directory-Felder begrenzen; Scanner-Engine-Versionen bleiben in der Quellprovenienz statt in `Directory Overview`. | | Eine minimierte Client-Info-Karte kann durch intrinsische Flexbreite schmaler als ihre Spalte werden; werkzeugspezifische Provenienz kann ausserdem erneut in einen fachlichen Verzeichnisueberblick geraten. | Collapsed-Karten explizit auf `width: 100%` setzen und diese Geometrie regressionspruefen. `directoryFacts` auf fachliche Directory-Felder begrenzen; Scanner-Engine-Versionen bleiben in der Quellprovenienz statt in `Directory Overview`. |
| Client-Info-Karten koennen beim Ein-/Ausklappen zwischen Spalten wechseln und dadurch die erlernte Position verlieren; unterschiedlich hohe Kopfbereiche koennen ausserdem eine grosse Leerstelle vor der Inhaltsmatrix erzeugen. | Karten statisch in drei semantische Spalten mit der Zuordnung 3/2/2 gruppieren und innerhalb jeder Spalte per Flex-Stack anordnen. Keine dichte Auto-Platzierung oder laufzeitbasierte Spannenberechnung verwenden. KPI-Matrix und Assessment-Status in derselben gestreckten Kopfzeile fuehren; an schmalen Breakpoints kontrolliert stapeln. Spaltenzahl, Karteninstanzen, Reihenfolge und Collapse regressionspruefen. | | Client-Info-Karten koennen beim Ein-/Ausklappen zwischen Spalten wechseln und dadurch die erlernte Position verlieren; unterschiedlich hohe Kopfbereiche koennen ausserdem eine grosse Leerstelle vor der Inhaltsmatrix erzeugen. | Karten statisch in drei semantische Spalten mit der Zuordnung 3/2/2 gruppieren und innerhalb jeder Spalte per Flex-Stack anordnen. Keine dichte Auto-Platzierung oder laufzeitbasierte Spannenberechnung verwenden. KPI-Matrix und Assessment-Status in derselben gestreckten Kopfzeile fuehren; an schmalen Breakpoints kontrolliert stapeln. Spaltenzahl, Karteninstanzen, Reihenfolge und Collapse regressionspruefen. |
@ -2355,6 +2357,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung | | Datum | Änderung |
| 2026-09-15 | Phase 12/13C, ausdruecklicher Nutzerauftrag: Nicht-personenbezogene PingCastle-Umgebungsaggregate fuer Result DB und AI Overview freigegeben. Ein reiner Ableitungsschritt (`derivePingCastleEnvironmentAggregates`) erzeugt aus bereits extrahierten Rohzellen stabil benannte Zaehl- und Konfigurationsbeobachtungen (Benutzer-/Computerkonten inkl. aktiviert/deaktiviert/aktiv und Passwort-laeuft-nie, Admin-Gruppen, Trusts, PKI-Zaehlungen, Passwortrichtlinie, LAPS-Praesenz, Entra-/krbtgt-Zustand), ohne Quellzellen, Identitaeten oder Zeitstempel zu veraendern. Die fail-closed Offenlegungsgrenze `result-db-supplemental-disclosure` erhaelt exakte Allowlist-Regeln je Property; `SAFE_ENVIRONMENT_PROPERTIES` gibt dieselben Aggregate an das Privacy-Safe-AI-JSON weiter. Verzeichnis-/Forest-/NetBIOS-Namen, bewerteter Host und alle Datums-/Zeitwerte bleiben ausdruecklich ausgeschlossen. PingCastle-Verarbeitungsvertrag 1.5.0 → 1.6.0 (`compatible`, `environment-context`, reimportRequired); 1.5.0 bleibt historisch und wird `reimport-recommended`, der freigegebene Dummy-Bericht benoetigt einen Reimport, um die Aggregate zu erhalten. 1.290 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Neue synthetische Ableitungs-, Offenlegungs- und AI-Export-Tests; reale Werte nur inhaltsfrei bzw. aggregiert im vom Nutzer ausdruecklich freigegebenen Dummy geprueft. Zusatz: Die Umgebungsfakten im Privacy-Safe-JSON/AI-Prompt entfallen um die konstanten Felder `state` (bei offengelegten Werten stets `observed`) und `count` (nach Deduplizierung je Aggregat 1); reine Serialisierungsanpassung ueber bereits gespeicherte Felder ohne Reimportbedarf. |
| 2026-09-14 | Phasen 7/13C und 11: Work-LiteLLM-gestuetztes AI Overview umgesetzt. Der Backend-Service liest den kompletten Timeslot ueber das bestehende Privacy-Safe-Result-DB-JSON, hasht ihn deterministisch, zerlegt grosse Payloads nur zwischen vollstaendigen Eintraegen und konsolidiert sie mit maximal 64 explizit nicht wiederholten LiteLLM-Aufrufen. Der HTTPS-Client haelt URL, Modell und Bearer-Key ausschliesslich in Server-Secrets, lehnt lokale/unsichere Ziele und Redirects ab und begrenzt Zeit sowie Antwortbytes. Ausgabevalidierung prueft das vollstaendige Dashboard-Schema, Laengen, Roadmap 0-4, exakte Finding-/Gap-IDs und Environment-Properties. Same-Origin-GET/POST, Migration 024, lokale/PostgreSQL-Repositoryparitaet, Projekt-Export/-Loeschung, Idempotenz und ein aktiver Run je Scope sind ergaenzt. Client Info rendert alle Analyseabschnitte mit Generate/Regenerate, Polling nur bei aktiven Runs sowie stale/failed/disabled Zustaenden. Sicherheits- und Deploymentdokumentation beschreibt den genehmigten Corporate-Gateway-Vertrag. 1.262 synthetische Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Der inhaltsfreie Workstation-Livecheck erreichte Work-LiteLLM, erhielt aber HTTP 401; Providerbody und Secrets wurden nicht ausgegeben. Docker/kanonisches Backup, Migrationanwendung, PostgreSQL-Integration sowie erfolgreicher Workstation-/Server-Livecheck bleiben offen; keine Realdateninspektion oder Reimportaenderung. | | 2026-09-14 | Phasen 7/13C und 11: Work-LiteLLM-gestuetztes AI Overview umgesetzt. Der Backend-Service liest den kompletten Timeslot ueber das bestehende Privacy-Safe-Result-DB-JSON, hasht ihn deterministisch, zerlegt grosse Payloads nur zwischen vollstaendigen Eintraegen und konsolidiert sie mit maximal 64 explizit nicht wiederholten LiteLLM-Aufrufen. Der HTTPS-Client haelt URL, Modell und Bearer-Key ausschliesslich in Server-Secrets, lehnt lokale/unsichere Ziele und Redirects ab und begrenzt Zeit sowie Antwortbytes. Ausgabevalidierung prueft das vollstaendige Dashboard-Schema, Laengen, Roadmap 0-4, exakte Finding-/Gap-IDs und Environment-Properties. Same-Origin-GET/POST, Migration 024, lokale/PostgreSQL-Repositoryparitaet, Projekt-Export/-Loeschung, Idempotenz und ein aktiver Run je Scope sind ergaenzt. Client Info rendert alle Analyseabschnitte mit Generate/Regenerate, Polling nur bei aktiven Runs sowie stale/failed/disabled Zustaenden. Sicherheits- und Deploymentdokumentation beschreibt den genehmigten Corporate-Gateway-Vertrag. 1.262 synthetische Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Der inhaltsfreie Workstation-Livecheck erreichte Work-LiteLLM, erhielt aber HTTP 401; Providerbody und Secrets wurden nicht ausgegeben. Docker/kanonisches Backup, Migrationanwendung, PostgreSQL-Integration sowie erfolgreicher Workstation-/Server-Livecheck bleiben offen; keine Realdateninspektion oder Reimportaenderung. |
| 2026-09-14 | Rebase der AI-Integration auf defender-iot: Die einzelne AI-Commit-Aenderung wurde neu aufgesetzt und die kollidierende Result-DB-LLM-Uebergabe zusammengefuehrt. Das versionierte, datensparsame Privacy-Safe-JSON-Schema dieses Branches ist nun das Result-DB-Exportformat und wird als einzelne Aktion `Export JSON` ueber den gemeinsamen Browser-Download-Adapter als `.json`-Datei geladen; damit ersetzt es sowohl den zuvor auf defender-iot vorhandenen `Export JSONL`-Download als auch die fluechtige `Copy JSON`-Zwischenablageaktion. Der Builder wurde zu `resultsToPrivacySafeExportJson` mit begleitendem Dateinamens-Helfer `privacySafeResultJsonFileName` umbenannt; Entscheidungs-, Risiko- und Testeintraege wurden entsprechend angeglichen. Alle uebrigen defender-iot-Inhalte (CVE-Enrichment, GPO-/Policy-Ergebnisse, Timeslot-Scope) bleiben unveraendert. 1248 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. | | 2026-09-14 | Rebase der AI-Integration auf defender-iot: Die einzelne AI-Commit-Aenderung wurde neu aufgesetzt und die kollidierende Result-DB-LLM-Uebergabe zusammengefuehrt. Das versionierte, datensparsame Privacy-Safe-JSON-Schema dieses Branches ist nun das Result-DB-Exportformat und wird als einzelne Aktion `Export JSON` ueber den gemeinsamen Browser-Download-Adapter als `.json`-Datei geladen; damit ersetzt es sowohl den zuvor auf defender-iot vorhandenen `Export JSONL`-Download als auch die fluechtige `Copy JSON`-Zwischenablageaktion. Der Builder wurde zu `resultsToPrivacySafeExportJson` mit begleitendem Dateinamens-Helfer `privacySafeResultJsonFileName` umbenannt; Entscheidungs-, Risiko- und Testeintraege wurden entsprechend angeglichen. Alle uebrigen defender-iot-Inhalte (CVE-Enrichment, GPO-/Policy-Ergebnisse, Timeslot-Scope) bleiben unveraendert. 1248 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. |
| 2026-09-14 | Phase 6/13A: `Site` und `Sensor` in der synthetischen Mock-Defender-`cves.csv` ergaenzt, sodass die Phase-7-CVE-Ansicht auch beim Einzelimport den Standort und meldenden Sensor zeigt. Schema-Metadaten, README, Validierungsbericht, Manifestspaltenzahl und SHA-256 wurden konsistent aktualisiert. Eine strukturgleiche synthetische Importregression prueft alle fuenf sichtbaren CVE-Kontexte auch nach kanonischer Persistenzprojektion. Alle 19 lokalen Mock-CSVs bestehen Zeilen-/Spalten-/Schema-/Hashpruefung; 1.245 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Reine synthetische Fixture-Korrektur; keine Parser-, Profil-, Persistenz-, Schema- oder Realdatenaenderung. | | 2026-09-14 | Phase 6/13A: `Site` und `Sensor` in der synthetischen Mock-Defender-`cves.csv` ergaenzt, sodass die Phase-7-CVE-Ansicht auch beim Einzelimport den Standort und meldenden Sensor zeigt. Schema-Metadaten, README, Validierungsbericht, Manifestspaltenzahl und SHA-256 wurden konsistent aktualisiert. Eine strukturgleiche synthetische Importregression prueft alle fuenf sichtbaren CVE-Kontexte auch nach kanonischer Persistenzprojektion. Alle 19 lokalen Mock-CSVs bestehen Zeilen-/Spalten-/Schema-/Hashpruefung; 1.245 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Reine synthetische Fixture-Korrektur; keine Parser-, Profil-, Persistenz-, Schema- oder Realdatenaenderung. |

View File

@ -36,7 +36,8 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
{ profileId: 'pingcastle', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'presentation-only', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: false }, { profileId: 'pingcastle', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'presentation-only', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation'], current: false }, { profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.4.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false }, { profileId: 'pingcastle', profileVersion: '1.4.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.5.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'import-diagnostics'], current: true }, { profileId: 'pingcastle', profileVersion: '1.5.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'import-diagnostics'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.6.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: true },
{ profileId: 'cis-cat', profileVersion: '1.2.0', mappingVersion: '1.2.0', normalizationVersion: '1.2.0', presentationVersion: '1.2.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence'], current: false }, { profileId: 'cis-cat', profileVersion: '1.2.0', mappingVersion: '1.2.0', normalizationVersion: '1.2.0', presentationVersion: '1.2.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence'], current: false },
{ profileId: 'cis-cat', profileVersion: '1.3.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: ['evidence', 'presentation'], current: false }, { profileId: 'cis-cat', profileVersion: '1.3.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: ['evidence', 'presentation'], current: false },
{ profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: true }, { profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: true },

View File

@ -98,7 +98,46 @@ const pingCastleRules: DisclosureRule[] = [
toolId: 'pingcastle', property: 'kerberos.krbtgt.version', title: 'krbtgt version', toolId: 'pingcastle', property: 'kerberos.krbtgt.version', title: 'krbtgt version',
subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata', subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata',
disclose: value => integer(value, 0, 1000000) disclose: value => integer(value, 0, 1000000)
} },
// Non-identifying aggregate counts and configuration state derived from
// PingCastle inventory tables. Each value is a plain count or boolean; no
// account, host, or directory identifier crosses this boundary.
...([
['user.account_count', 'User accounts'],
['user.account_enabled_count', 'Enabled user accounts'],
['user.account_disabled_count', 'Disabled user accounts'],
['user.account_active_count', 'Active user accounts'],
['user.password_never_expires_count', 'Users with non-expiring passwords'],
['computer.account_count', 'Computer accounts'],
['computer.account_enabled_count', 'Enabled computer accounts'],
['computer.account_disabled_count', 'Disabled computer accounts'],
['computer.account_active_count', 'Active computer accounts'],
['admin_group.count', 'Administrative groups'],
['trust.count', 'Domain trusts'],
['pki.certificate_template_count', 'Certificate templates'],
['pki.certificate_count', 'Certificates'],
['pki.dc_certificate_count', 'Domain controller certificates'],
['pki.delegation_count', 'PKI delegations'],
['operating_system.count', 'Distinct operating systems'],
['password_policy.minimum_length', 'Password minimum length'],
['password_policy.history_length', 'Password history length'],
['password_policy.lockout_threshold', 'Account lockout threshold'],
['password_policy.maximum_age_days', 'Maximum password age (days)']
] as const).map(([property, title]): DisclosureRule => ({
toolId: 'pingcastle', property, title,
subjectType: 'active-directory-environment', disclosureClass: 'aggregate',
disclose: value => integer(value, 0, 1_000_000_000)
})),
...([
['password_policy.complexity_enabled', 'Password complexity enabled'],
['entra.configuration_detected', 'Entra ID configuration detected'],
['laps.legacy.present', 'Legacy LAPS present'],
['laps.windows.present', 'Windows LAPS present']
] as const).map(([property, title]): DisclosureRule => ({
toolId: 'pingcastle', property, title,
subjectType: 'active-directory-environment', disclosureClass: 'configuration-state',
disclose: booleanValue
}))
]; ];
const disclosureRules = new Map( const disclosureRules = new Map(

View File

@ -7,7 +7,34 @@ const SAFE_ENVIRONMENT_PROPERTIES = new Set([
'forest.functional_level', 'forest.functional_level',
'logging.wef.configuration_count', 'logging.wef.configuration_count',
'operating_system.count', 'operating_system.count',
'operating_system.name' 'operating_system.name',
// Non-identifying environment aggregates and configuration state disclosed to
// Result DB (see result-db-supplemental-disclosure.ts). These are plain counts
// and booleans; no account, host, or directory identifier is included.
'user.account_count',
'user.account_enabled_count',
'user.account_disabled_count',
'user.account_active_count',
'user.password_never_expires_count',
'computer.account_count',
'computer.account_enabled_count',
'computer.account_disabled_count',
'computer.account_active_count',
'admin_group.count',
'trust.count',
'pki.certificate_template_count',
'pki.certificate_count',
'pki.dc_certificate_count',
'pki.delegation_count',
'password_policy.minimum_length',
'password_policy.history_length',
'password_policy.lockout_threshold',
'password_policy.maximum_age_days',
'password_policy.complexity_enabled',
'entra.configuration_detected',
'kerberos.krbtgt.version',
'laps.legacy.present',
'laps.windows.present'
]); ]);
export function privacySafeResultJsonFileName(generatedAt: string): string { export function privacySafeResultJsonFileName(generatedAt: string): string {
@ -67,24 +94,21 @@ function groupedEnvironmentFacts(items: UnifiedResultProjection[]) {
title: string; title: string;
property: string; property: string;
value: unknown; value: unknown;
state: string;
tool: string; tool: string;
count: number;
}>(); }>();
items.forEach(item => { items.forEach(item => {
const property = copiedEnvironmentProperty(item); const property = copiedEnvironmentProperty(item);
if (!property) return; if (!property) return;
// A disclosed environment fact is always an observed value, and after
// deduplication each aggregate property is unique; the previous per-fact
// state and count fields were therefore constant noise in the AI input.
const fact = { const fact = {
title: privacySafeText(item.title), title: privacySafeText(item.title),
property, property,
value: item.environmentNormalizedValue ?? null, value: item.environmentNormalizedValue ?? null,
state: item.environmentState ?? 'unknown',
tool: item.sourceTool ?? 'unknown' tool: item.sourceTool ?? 'unknown'
}; };
const key = JSON.stringify(fact); grouped.set(JSON.stringify(fact), fact);
const existing = grouped.get(key);
if (existing) existing.count += 1;
else grouped.set(key, { ...fact, count: 1 });
}); });
return [...grouped.values()]; return [...grouped.values()];
} }

View File

@ -0,0 +1,116 @@
import type { JsonValue } from '../domain/canonical-model';
import type { EnvironmentObservationCandidate } from '../domain/source-extraction';
/**
* Ableitung stabil benannter, nicht-personenbezogener Aggregatbeobachtungen aus
* bereits extrahierten PingCastle-Umgebungsbeobachtungen. Die Rohzellen tragen
* report-abhaengige, fragile Property-Schluessel (z. B.
* `pingcastle.sectionuserinformation.tabelle.27.nb.user.accounts`). Die
* Result-DB-Offenlegungsgrenze (`discloseSupplementalObservation`) verlangt
* jedoch einen exakten, stabilen Tool/Property-Schluessel. Diese Ableitung
* erzeugt genau solche stabilen Aggregatwerte, ohne Quellzellen zu veraendern.
*
* Es werden ausschliesslich Zaehlungen und boolesche Konfigurationszustaende
* abgeleitet. Es entstehen keine neuen Roh- oder Identifikationsdaten.
*/
interface AggregateRule {
property: string;
/** Exact source subject type to match, or a prefix when `subjectTypePrefix` is true. */
subjectType: string;
subjectTypePrefix?: boolean;
label: RegExp;
kind: 'integer' | 'boolean' | 'integer-with-unit';
}
const AGGREGATE_RULES: readonly AggregateRule[] = [
{ property: 'user.account_count', subjectType: 'user-information.account-analysis', label: /^nb user accounts$/i, kind: 'integer' },
{ property: 'user.account_enabled_count', subjectType: 'user-information.account-analysis', label: /^nb enabled$/i, kind: 'integer' },
{ property: 'user.account_disabled_count', subjectType: 'user-information.account-analysis', label: /^nb disabled$/i, kind: 'integer' },
{ property: 'user.account_active_count', subjectType: 'user-information.account-analysis', label: /^nb active$/i, kind: 'integer' },
{ property: 'user.password_never_expires_count', subjectType: 'user-information.account-analysis', label: /^nb pwd never expire$/i, kind: 'integer' },
{ property: 'computer.account_count', subjectType: 'computer-information.account-analysis', label: /^nb computer accounts$/i, kind: 'integer' },
{ property: 'computer.account_enabled_count', subjectType: 'computer-information.account-analysis', label: /^nb enabled$/i, kind: 'integer' },
{ property: 'computer.account_disabled_count', subjectType: 'computer-information.account-analysis', label: /^nb disabled$/i, kind: 'integer' },
{ property: 'computer.account_active_count', subjectType: 'computer-information.account-analysis', label: /^nb active$/i, kind: 'integer' },
{ property: 'admin_group.count', subjectType: 'admin-groups.groups', label: /^admin groups list row count$/i, kind: 'integer' },
{ property: 'trust.count', subjectType: 'trusts-details.discovered-domains', label: /^trusts list row count$/i, kind: 'integer' },
{ property: 'pki.certificate_template_count', subjectType: 'pki.certificate-templates', label: /^certificate templates list row count$/i, kind: 'integer' },
{ property: 'pki.certificate_count', subjectType: 'pki.certificates', label: /^certificates list row count$/i, kind: 'integer' },
{ property: 'pki.dc_certificate_count', subjectType: 'pki.domain-controller-certificate', label: /^dc certificates list row count$/i, kind: 'integer' },
{ property: 'pki.delegation_count', subjectType: 'pki.', subjectTypePrefix: true, label: /^delegations list row count$/i, kind: 'integer' },
{ property: 'password_policy.complexity_enabled', subjectType: 'password-policies.password-policies', label: /^complexity$/i, kind: 'boolean' },
{ property: 'password_policy.lockout_threshold', subjectType: 'password-policies.password-policies', label: /^lockout threshold$/i, kind: 'integer' },
{ property: 'password_policy.minimum_length', subjectType: 'password-policies.password-policies', label: /^min password length$/i, kind: 'integer' },
{ property: 'password_policy.maximum_age_days', subjectType: 'password-policies.password-policies', label: /^max password age$/i, kind: 'integer-with-unit' }
];
/** Presence booleans derived from the LAPS installation-date facts. */
const LAPS_PRESENCE_RULES: ReadonlyArray<{ sourceProperty: string; property: string; label: string }> = [
{ sourceProperty: 'laps.legacy.installation_date', property: 'laps.legacy.present', label: 'Legacy LAPS present' },
{ sourceProperty: 'laps.windows.installation_date', property: 'laps.windows.present', label: 'Windows LAPS present' }
];
function integerFrom(value: JsonValue | undefined, kind: AggregateRule['kind']): number | undefined {
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
if (kind === 'integer-with-unit' && typeof value === 'string') {
const match = /^\s*(-?\d+)/.exec(value);
return match ? Number(match[1]) : undefined;
}
return undefined;
}
export function derivePingCastleEnvironmentAggregates(
observations: readonly EnvironmentObservationCandidate[]
): EnvironmentObservationCandidate[] {
const existingProperties = new Set(observations.map(observation => observation.property));
const derived: EnvironmentObservationCandidate[] = [];
const emit = (
source: EnvironmentObservationCandidate,
property: string,
label: string,
normalizedValue: JsonValue,
valueType: 'number' | 'boolean'
) => {
if (existingProperties.has(property)) return;
existingProperties.add(property);
derived.push({
version: 1,
subject: { type: 'active-directory-environment' },
property,
label,
originalValue: source.originalValue,
normalizedValue,
valueType,
state: 'observed',
sectionKey: source.sectionKey,
source: { ...source.source, objectPath: `derived/environment-aggregate/${property}` },
attributes: {}
});
};
for (const rule of AGGREGATE_RULES) {
const source = observations.find(observation =>
(rule.subjectTypePrefix ? observation.subject.type.startsWith(rule.subjectType) : observation.subject.type === rule.subjectType)
&& rule.label.test(observation.label));
if (!source) continue;
const value = source.normalizedValue ?? source.originalValue;
if (rule.kind === 'boolean') {
if (typeof value !== 'boolean') continue;
emit(source, rule.property, source.label, value, 'boolean');
continue;
}
const numeric = integerFrom(value, rule.kind);
if (numeric === undefined) continue;
emit(source, rule.property, source.label, numeric, 'number');
}
for (const rule of LAPS_PRESENCE_RULES) {
const source = observations.find(observation => observation.property === rule.sourceProperty);
if (!source) continue;
emit(source, rule.property, rule.label, source.state === 'observed', 'boolean');
}
return derived;
}

View File

@ -18,6 +18,7 @@ import { protectHtmlAttributeMarkup } from './html-attribute-markup';
import { htmlAttributeValue as attributeValue } from './html-attributes'; import { htmlAttributeValue as attributeValue } from './html-attributes';
import { indexHtmlElements, type HtmlElementIndex } from './html-element-index'; import { indexHtmlElements, type HtmlElementIndex } from './html-element-index';
import { acceptPingCastleRuleId, pingCastleHtmlRuleCoverage } from './pingcastle-rule-validation'; import { acceptPingCastleRuleId, pingCastleHtmlRuleCoverage } from './pingcastle-rule-validation';
import { derivePingCastleEnvironmentAggregates } from './pingcastle-environment-aggregates';
const maximumRuleCandidates = 5_000; const maximumRuleCandidates = 5_000;
const maximumFieldMarkupCharacters = 1_000_000; const maximumFieldMarkupCharacters = 1_000_000;
@ -1027,6 +1028,7 @@ function contextObservations(
source: { fileName: document.file.name, objectPath: 'html/facts/entra-configuration' } source: { fileName: document.file.name, objectPath: 'html/facts/entra-configuration' }
}); });
} }
observations.push(...derivePingCastleEnvironmentAggregates(observations));
return observations; return observations;
} }

View File

@ -26,11 +26,11 @@ export const pingCastleDefinition: ToolProfileDefinition = {
targets: ['domain.fqdn'], resultPopulation: 'findings-only' targets: ['domain.fqdn'], resultPopulation: 'findings-only'
}, },
name: 'PingCastle', name: 'PingCastle',
version: '1.5.0', version: '1.6.0',
processing: { processing: {
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible',
reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'import-diagnostics'] reimportRequired: true, affectedDimensions: ['environment-context']
}, },
sourceSignals: [ sourceSignals: [
{ scope: 'file-name', pattern: /ping[\s_.-]*castle|^ad[_-]hc[_-]/i, contribution: 0.5, message: 'Der Dateiname entspricht einem PingCastle-Healthcheck.' }, { scope: 'file-name', pattern: /ping[\s_.-]*castle|^ad[_-]hc[_-]/i, contribution: 0.5, message: 'Der Dateiname entspricht einem PingCastle-Healthcheck.' },

View File

@ -29,7 +29,7 @@ describe('processing compatibility contracts', () => {
const old = file('old', 'pingcastle', '1.1.0', '1.1.0'); const old = file('old', 'pingcastle', '1.1.0', '1.1.0');
const contextless = file('contextless', 'pingcastle', '1.2.0', '1.1.0'); const contextless = file('contextless', 'pingcastle', '1.2.0', '1.1.0');
const previous = file('previous', 'pingcastle', '1.4.0', '1.2.0'); const previous = file('previous', 'pingcastle', '1.4.0', '1.2.0');
const current = file('current', 'pingcastle', '1.5.0', '1.2.0'); const current = file('current', 'pingcastle', '1.6.0', '1.2.0');
expect(resolvedCompatibilityKey('pingcastle', old.processedWith)).toBe('pingcastle-canonical-v1.1'); expect(resolvedCompatibilityKey('pingcastle', old.processedWith)).toBe('pingcastle-canonical-v1.1');
expect(resolvedCompatibilityKey('pingcastle', contextless.processedWith)).toBe('pingcastle-canonical-v1.1'); expect(resolvedCompatibilityKey('pingcastle', contextless.processedWith)).toBe('pingcastle-canonical-v1.1');

View File

@ -69,4 +69,31 @@ describe('Result DB supplemental disclosure boundary', () => {
property: 'risk.domain', normalizedValue: 101 property: 'risk.domain', normalizedValue: 101
}))).toBeUndefined(); }))).toBeUndefined();
}); });
it('discloses non-identifying environment aggregates and configuration state', () => {
expect(discloseSupplementalObservation(observation({
property: 'user.account_count', normalizedValue: 16, originalValue: 'Private tenant: 16', valueType: 'number'
}))).toMatchObject({ property: 'user.account_count', value: 16, disclosureClass: 'aggregate', subjectType: 'active-directory-environment' });
expect(discloseSupplementalObservation(observation({
property: 'computer.account_count', normalizedValue: 1, valueType: 'number'
}))).toMatchObject({ value: 1, disclosureClass: 'aggregate' });
expect(discloseSupplementalObservation(observation({
property: 'user.password_never_expires_count', normalizedValue: 15, valueType: 'number'
}))).toMatchObject({ value: 15, disclosureClass: 'aggregate' });
expect(discloseSupplementalObservation(observation({
property: 'pki.certificate_template_count', normalizedValue: 12, valueType: 'number'
}))).toMatchObject({ value: 12, disclosureClass: 'aggregate' });
expect(discloseSupplementalObservation(observation({
property: 'password_policy.complexity_enabled', normalizedValue: false, valueType: 'boolean'
}))).toMatchObject({ value: false, disclosureClass: 'configuration-state' });
expect(discloseSupplementalObservation(observation({
property: 'laps.windows.present', normalizedValue: true, valueType: 'boolean'
}))).toMatchObject({ value: true, disclosureClass: 'configuration-state' });
});
it('still fails closed for a non-numeric aggregate value', () => {
expect(discloseSupplementalObservation(observation({
property: 'user.account_count', normalizedValue: 'sixteen private users', valueType: 'string'
}))).toBeUndefined();
});
}); });

View File

@ -73,8 +73,43 @@ describe('Result DB compact privacy-safe JSON export', () => {
expect(copied.summary).toMatchObject({ totalResults: 3, findingResults: 0, environmentResults: 3 }); expect(copied.summary).toMatchObject({ totalResults: 3, findingResults: 0, environmentResults: 3 });
expect(copied.environmentFacts).toEqual([{ expect(copied.environmentFacts).toEqual([{
title: 'Operating System', property: 'operating_system.name', value: 'Windows Server 2022', title: 'Operating System', property: 'operating_system.name', value: 'Windows Server 2022',
state: 'observed', tool: 'synthetic-tool', count: 2 tool: 'synthetic-tool'
}]); }]);
expect(JSON.stringify(copied)).not.toMatch(/PRIVATE/); expect(JSON.stringify(copied)).not.toMatch(/PRIVATE/);
}); });
it('includes non-identifying environment aggregates and configuration state in the AI export', () => {
const facts: UnifiedResultProjection[] = [
['user.account_count', 'number', 16],
['computer.account_count', 'number', 1],
['user.password_never_expires_count', 'number', 15],
['admin_group.count', 'number', 14],
['pki.certificate_template_count', 'number', 12],
['password_policy.minimum_length', 'number', 5],
['password_policy.complexity_enabled', 'boolean', false],
['laps.windows.present', 'boolean', false]
].map(([property, valueType, value]) => result({
sourceKind: 'environment-observation', title: `Fact ${property}`, environmentState: 'observed',
environmentSubjectType: 'active-directory-environment',
environmentProperty: property as string, environmentValueType: valueType as 'number' | 'boolean',
environmentNormalizedValue: value as number | boolean
}));
const excludedIdentifier = result({
sourceKind: 'environment-observation', title: 'Domain', environmentState: 'observed',
environmentProperty: 'domain.fqdn', environmentValueType: 'string', environmentNormalizedValue: 'PRIVATE.EXAMPLE'
});
const copied = JSON.parse(resultsToPrivacySafeExportJson([...facts, excludedIdentifier]));
const byProperty = new Map(copied.environmentFacts.map((fact: { property: string; value: unknown }) => [fact.property, fact.value]));
expect(byProperty.get('user.account_count')).toBe(16);
expect(byProperty.get('computer.account_count')).toBe(1);
expect(byProperty.get('user.password_never_expires_count')).toBe(15);
expect(byProperty.get('admin_group.count')).toBe(14);
expect(byProperty.get('pki.certificate_template_count')).toBe(12);
expect(byProperty.get('password_policy.minimum_length')).toBe(5);
expect(byProperty.get('password_policy.complexity_enabled')).toBe(false);
expect(byProperty.get('laps.windows.present')).toBe(false);
expect(byProperty.has('domain.fqdn')).toBe(false);
expect(JSON.stringify(copied)).not.toMatch(/PRIVATE/);
});
}); });

View File

@ -87,7 +87,7 @@ describe('Datahub client processing readiness', () => {
file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1') file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')
])).toMatchObject({ ])).toMatchObject({
state: 'update', state: 'update',
explanation: 'ASSESSOR profile update: 1.1.0 → 1.5.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.' explanation: 'ASSESSOR profile update: 1.1.0 → 1.6.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
}); });
}); });
@ -96,7 +96,7 @@ describe('Datahub client processing readiness', () => {
file('run-contextless', 'pingcastle', '1.2.0', 'pingcastle-canonical-v1.1') file('run-contextless', 'pingcastle', '1.2.0', 'pingcastle-canonical-v1.1')
])).toMatchObject({ ])).toMatchObject({
state: 'update', state: 'update',
explanation: 'ASSESSOR profile update: 1.2.0 → 1.5.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.' explanation: 'ASSESSOR profile update: 1.2.0 → 1.6.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
}); });
}); });

View File

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import type { EnvironmentObservationCandidate } from '../../src/domain/source-extraction';
import { derivePingCastleEnvironmentAggregates } from '../../src/importers/pingcastle-environment-aggregates';
function cell(
subjectType: string,
label: string,
value: string | number | boolean,
valueType: EnvironmentObservationCandidate['valueType'],
state: EnvironmentObservationCandidate['state'] = 'observed'
): EnvironmentObservationCandidate {
return {
version: 1,
subject: { type: subjectType, identity: 'sevenkingdoms:private', label: 'Private tenant' },
property: `pingcastle.section.tabelle.99.${label.toLowerCase().replace(/[^a-z0-9]+/g, '.')}`,
label,
originalValue: value,
normalizedValue: value,
valueType,
state,
sectionKey: 'sectionSynthetic',
source: { fileName: 'ad_hc_report.html', objectPath: 'html/section/cell' },
attributes: {}
};
}
function byProperty(observations: EnvironmentObservationCandidate[]) {
return new Map(observations.map(observation => [observation.property, observation.normalizedValue]));
}
describe('PingCastle environment aggregate derivation', () => {
it('derives stable account, inventory, policy and configuration aggregates from raw cells', () => {
const input: EnvironmentObservationCandidate[] = [
cell('user-information.account-analysis', 'Nb User Accounts', 16, 'number'),
cell('user-information.account-analysis', 'Nb Enabled', 15, 'number'),
cell('user-information.account-analysis', 'Nb Disabled', 1, 'number'),
cell('user-information.account-analysis', 'Nb Active', 15, 'number'),
cell('user-information.account-analysis', 'Nb pwd never Expire', 15, 'number'),
cell('computer-information.account-analysis', 'Nb Computer Accounts', 1, 'number'),
cell('computer-information.account-analysis', 'Nb Enabled', 1, 'number'),
cell('admin-groups.groups', 'Admin groups list row count', 14, 'number'),
cell('trusts-details.discovered-domains', 'Trusts list row count', 0, 'number'),
cell('pki.certificate-templates', 'Certificate Templates list row count', 12, 'number'),
cell('pki.certificate-templates', 'Delegations list row count', 12, 'number'),
cell('pki.certificates', 'Certificates list row count', 1, 'number'),
cell('pki.domain-controller-certificate', 'DC Certificates list row count', 1, 'number'),
cell('password-policies.password-policies', 'Complexity', false, 'boolean'),
cell('password-policies.password-policies', 'Lockout Threshold', 5, 'number'),
cell('password-policies.password-policies', 'Min Password Length', 5, 'number'),
cell('password-policies.password-policies', 'Max Password Age', '37201 day(s)', 'string'),
cell('anomalies.laps', 'Legacy LAPS installation date', 'Never', 'datetime', 'explicitly-absent'),
cell('anomalies.laps', 'Windows LAPS installation date', 'Never', 'datetime', 'explicitly-absent')
];
// The LAPS presence booleans key off the canonical installation-date facts.
input.push({
...cell('anomalies.laps', 'Legacy LAPS installation date', 'Never', 'datetime', 'explicitly-absent'),
property: 'laps.legacy.installation_date'
});
input.push({
...cell('anomalies.laps', 'Windows LAPS installation date', '2026-05-27T10:00:00Z', 'datetime', 'observed'),
property: 'laps.windows.installation_date'
});
const derived = derivePingCastleEnvironmentAggregates(input);
const values = byProperty(derived);
expect(values.get('user.account_count')).toBe(16);
expect(values.get('user.account_enabled_count')).toBe(15);
expect(values.get('user.account_disabled_count')).toBe(1);
expect(values.get('user.account_active_count')).toBe(15);
expect(values.get('user.password_never_expires_count')).toBe(15);
expect(values.get('computer.account_count')).toBe(1);
expect(values.get('computer.account_enabled_count')).toBe(1);
expect(values.get('admin_group.count')).toBe(14);
expect(values.get('trust.count')).toBe(0);
expect(values.get('pki.certificate_template_count')).toBe(12);
expect(values.get('pki.delegation_count')).toBe(12);
expect(values.get('pki.certificate_count')).toBe(1);
expect(values.get('pki.dc_certificate_count')).toBe(1);
expect(values.get('password_policy.complexity_enabled')).toBe(false);
expect(values.get('password_policy.lockout_threshold')).toBe(5);
expect(values.get('password_policy.minimum_length')).toBe(5);
expect(values.get('password_policy.maximum_age_days')).toBe(37201);
expect(values.get('laps.legacy.present')).toBe(false);
expect(values.get('laps.windows.present')).toBe(true);
// Every derived value is a plain count or boolean under the shared AD subject;
// no source identity, label text, or raw path is carried through.
derived.forEach(observation => {
expect(observation.subject.type).toBe('active-directory-environment');
expect(['number', 'boolean']).toContain(observation.valueType);
expect(observation.subject.identity).toBeUndefined();
});
expect(JSON.stringify(derived)).not.toMatch(/private|sevenkingdoms/i);
});
it('emits nothing for unrelated observations and never overwrites an existing stable property', () => {
expect(derivePingCastleEnvironmentAggregates([
cell('gpo.login', 'Some login row', 3, 'number')
])).toEqual([]);
const preexisting = derivePingCastleEnvironmentAggregates([
{ ...cell('active-directory-environment', 'User accounts', 99, 'number'), property: 'user.account_count' },
cell('user-information.account-analysis', 'Nb User Accounts', 16, 'number')
]);
expect(preexisting.some(observation => observation.property === 'user.account_count')).toBe(false);
});
});

View File

@ -90,7 +90,7 @@ describe('PingCastle native finding extraction', () => {
expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0); expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0);
expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0); expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0);
expect(model.importFiles[0].processedWith).toMatchObject({ expect(model.importFiles[0].processedWith).toMatchObject({
profileId: 'pingcastle', profileVersion: '1.5.0', mappingVersion: '1.2.0', profileId: 'pingcastle', profileVersion: '1.6.0', mappingVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1' compatibilityKey: 'pingcastle-canonical-v1.1'
}); });
expect(model.scanRuns[0].tool.version).toBe('3.4.0'); expect(model.scanRuns[0].tool.version).toBe('3.4.0');

View File

@ -261,7 +261,7 @@ describe('Unified result read model', () => {
format: 'HTML', format: 'HTML',
fileName: 'synthetic-pingcastle.html', fileName: 'synthetic-pingcastle.html',
processedWith: { 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.6.0',
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1' compatibilityKey: 'pingcastle-canonical-v1.1'
} }
@ -303,7 +303,7 @@ describe('Unified result read model', () => {
resultProjectionId: 'environment-observation:environment-dfl', resultProjectionId: 'environment-observation:environment-dfl',
sourceKind: 'environment-observation', sourceKind: 'environment-observation',
sourceTool: 'pingcastle', sourceTool: 'pingcastle',
sourceProfileVersion: '1.5.0', sourceProfileVersion: '1.6.0',
mappingRuleVersion: '1.2.0+context-1.0.0', mappingRuleVersion: '1.2.0+context-1.0.0',
processingStatus: 'current', processingStatus: 'current',
importArtifactId: importFile.id, importArtifactId: importFile.id,