diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 1a60294..593c630 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1954,6 +1954,10 @@ 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. | 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 | 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-07 | Die geladene Client Info ist innerhalb der Wurzelklasse `.client-command-center` eine feste dunkle Command-Center-Ausnahme von der allgemeinen Kachelkomposition. Die genehmigte Hierarchie ist Client/Assessment/Source, sechs Icon-Fakten, Overall Score mit acht visuell verbundenen Security Domains, Priority Findings plus Assessment Gaps und abschliessend fuenf werkzeugspezifische Source-Flaechen. | Die ausdruecklich genehmigte Referenz ist ein zusammenhaengendes Erstgespraechs-Dashboard und keine Sammlung allgemeiner Anwendungskacheln. Eine eng begrenzte Ausnahme bildet diese Hierarchie originalgetreu ab, ohne Data Hub, Work, Result DB, Pathfinder oder Shell visuell zu veraendern. | | 2026-09-07 | Die Nutzerpraezisierung erweitert die unmittelbar folgende Positionsentscheidung: `Source: Scan` ist eine workspaceweite Navigationsfacette mit stabiler ID und vollstaendiger Optionsliste aus dem projektweiten All-Records-Blatt. Eine Auswahl aus einem dedizierten GPO-/Fachblatt wechselt auf dieses Aggregat und aktiviert dort direkt den ausgewaehlten Scan; lokale Facettennachzaehlung darf die Liste nicht reduzieren. | Der Filter soll nicht nur die aktuell sichtbaren Records einschraenken, sondern jederzeit den direkten Wechsel zu jedem geoeffneten Scan ermoeglichen. Nur die projektweite Quelle und ein positionsunabhaengiger Identifikator verhindern, dass ein Fachblatt alle anderen Scanner aus der Auswahl verliert oder der gesetzte Filter beim Blattwechsel unbrauchbar wird. | | 2026-09-14 | Eine eindeutige `Source: Scan`-Auswahl ist auf dem projektweiten All-Records-Blatt ein bindender Werkzeugkontext. Fuer GPOs gelten dort derselbe Facetten- und Statusvertrag wie auf dem dedizierten GPO-Blatt; die Kontextreduktion geschieht vor allgemeiner Priorisierung und Facettenlimit, ungueltige Altfilter werden entfernt. Dokumentierte Nutzerentscheidungen sind Abnahmekriterien; ein Konflikt mit Projektregeln ist vor der Umsetzung zu melden und darf nicht still umgangen werden. | Eine reine Datensatzfilterung bei unveraendertem Mischprofil zeigt fachfremde Scannerfilter und kann unsichtbare Fremdfilter aktiv lassen. Der Kontext muss deshalb die angebotene und wirksame Filtermenge bestimmen. Transparente Konfliktmeldung erhaelt die Nachvollziehbarkeit bindender Vereinbarungen. | @@ -2351,6 +2355,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. | 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 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. | @@ -2664,6 +2669,24 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client | 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl statt eines leeren `Affected Object`. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. | | 2026-09-10 | Defender-Ergebnisfenster auf eine Device-Tabelle umgestellt: `Affected Devices` zeigt Device, Scanner und Site; maschinenlesbare `Source Observations` bleiben als Evidenz erhalten und werden in der Lesespalte ausgeblendet. Keine SQL- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. | | 2026-09-10 | Phase-13A-Defender-Projektion auf CVE-Aggregation umgestellt: Der Source Extractor emittiert genau ein Finding je CVE; betroffene Devices werden zusammengefasst, alle Sensorbeobachtungen bleiben erhalten, und der Vergleichsschluessel ist `rule-id`. Keine SQL- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. | +| 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 | 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-11 | Phase 6/13A: Defender-Import auf das synthetische `Mock-Defender`-CSV-Schema erweitert. `cves.csv` wird eigenstaendig erkannt und in aggregierte CVE-Findings ueberfuehrt; `device_inventory.csv` liefert per `Device ID` die Device-, Site- und Sensoranreicherung. Mock-spezifische CVSS-, Component- und Statuswerte bleiben Evidenz, fehlende Werte werden nicht erfunden. Source Extractor 1.1.0, Defender-Profil und Processing-Manifest 1.2.0 append-only registriert; Profil 1.1.0 wird als Reimportempfehlung sichtbar. Die Regression importiert die tatsaechliche synthetische Mock-CVE-Datei und prueft fuenf Findings sowie den Inventarjoin. 1.245 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und Client-/Serverbuild erfolgreich. Keine Realdaten-, Datenbank- oder Migrationsaenderung; Phasenstatus unveraendert. | +| 2026-09-11 | Phasen 2/6/7/11 um den eigenen Schwachstellen-Ergebnistyp erweitert: `vulnerability_finding` ist eine additive ASSESSOR-Erweiterung des unveraenderten externen Result Contract 1.0.0. Das Defender-Profil 1.1.0 setzt fuer statuslose CVE-Findings deklarativ `outcome=warning`; Work und Unified Result projizieren dadurch `result_type=vulnerability_finding`, `is_ioe=false` und `deviation=true`. Die bereits angewandte Migration 022 bleibt checksum-identisch und erweitert nur den geschlossenen PostgreSQL-Constraint; die nachtraeglich benoetigte Aktualisierung bestehender abgeleiteter Result-Type-/Outcome-Spalten liegt append-only in Migration 023. Originalfelder, Occurrence-JSON und Profilprovenienz bleiben unveraendert. Profil 1.0.0 bleibt wegen der geaenderten kanonischen Semantik inkompatibel und benoetigt fuer einen vollstaendig aktuellen Datensatz Reimport/Reprocessing. 787 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Phasen 2, 6, 7 und 11 bleiben abgeschlossen. | +| 2026-09-11 | Phase-7-Abweichungsfilter zwischen Work und Result DB vereinheitlicht: Work prueft `deviation` nicht mehr gegen beliebige profilbezogene Rohwertmuster wie Defender-Severity `Critical`, sondern rekonstruiert aus Werkzeug und Statusfeld dieselben kanonischen Achsen Outcome, Result Type, Exposure State und `is_ioe` und verwendet anschliessend das gemeinsame versionierte Abweichungspraedikat. Defender-CVEs ohne Status bleiben damit wie in der Result DB `outcome=unknown`, `is_ioe=false`, `deviation=false`; Fail-/Warning-Ergebnisse und festgestellte Expositionen bleiben Abweichungen. Keine Daten-, API-, Schema-, Persistenz- oder Profilversionsaenderung. 781 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Phase 7 bleibt abgeschlossen. | +| 2026-09-10 | Result-DB-Beschreibungsdeduplizierung fuer einzelne Defender-CVEs nachgehaertet: Scanner- und NVD-Beschreibung werden fuer den reinen Anzeigevergleich jetzt Unicode-NFKC-normalisiert, von unsichtbaren Formatzeichen bereinigt, case-insensitiv behandelt und ueber ihre Wort-/Zahlfolge verglichen. Dadurch werden visuell identische Saetze auch bei Soft-Hyphen-, Zeichensetzungs- oder Grossschreibungsabweichungen nicht doppelt gerendert; inhaltlich unterschiedliche Wortfolgen bleiben sichtbar. Gespeicherte Quelltexte bleiben unveraendert. Drei fokussierte Regressionstests und der Client-Produktionsbuild sind erfolgreich. | +| 2026-09-10 | Work-Darstellung der gruppierten NVD-Konfigurationen an die Originalstruktur angenaehert: Jede nummerierte `Configuration` rendert genau eine Tabelle. Nach den betroffenen CPE-Zeilen steht `Running on/with` als eigene Tabellenzeile; die zugeordneten Plattform-CPEs folgen innerhalb derselben Tabelle statt in einer zweiten Tabelle. Gruppierung, Speicherung und Reihenfolge der Findings bleiben unveraendert. 17 fokussierte Finding-/CVE-Darstellungstests und der Client-Produktionsbuild sind erfolgreich. | +| 2026-09-10 | NVD-Konfigurationsprojektion korrigiert: Der NVD-Adapter flacht CPE-Matches nicht mehr zu einzelnen Konfigurationen ab, sondern speichert jede top-level API-Konfiguration als eine Gruppe mit getrennten `vulnerableMatches` und `runningOnWith`. Work zeigt je Gruppe `Configuration n`, die betroffene Software mit Versionsbereich und darunter die zugehoerigen `Running on/with`-CPEs. Das Enrichmentformat ist Version 2; vorhandene Version-1-Daten bleiben defensiv lesbar und werden beim naechsten `Refresh from NVD` verlustfrei ersetzt. 33 fokussierte Parser-, Persistenz- und Darstellungsregressionen, beide TypeScript-Pruefungen sowie der vollstaendige Client-/Server-Produktionsbuild sind erfolgreich. Keine SQL-Migration und keine Aenderung der Scannerfindings. | +| 2026-09-10 | Work-Reader-Reihenfolge fuer `NVD Affected Configurations` nach Nutzerkorrektur vereinfacht: Das Feld bleibt Bestandteil des normalen Finding-Summary-Renders und erhaelt dort lediglich eine niedrigere Sortierprioritaet als die anderen regulaeren Felder. Der kurzzeitig eingefuehrte separate Abschlussabschnitt wurde vollstaendig entfernt. Die sichtbare Tabelle steht damit normal nach `NVD Metrics` und den weiteren NVD-Feldern. 17 fokussierte Finding-/CVE-Tests und der Client-Produktionsbuild sind erfolgreich. Keine Daten-, API- oder Persistenzaenderung. | +| 2026-09-10 | NVD-Betroffenheitskonfigurationen an das Ende des Findings verschoben: In der Work-Projektion ist `NVD Affected Configurations` nun das letzte angereicherte Feld nach `NVD Record`; in der erweiterten Result-DB-Zeile steht die gleich benannte Anzahl nach Referenzen und NVD-Link als letzter Inhalt. Speicherung und Quellwerte bleiben unveraendert. Drei fokussierte CVE-Darstellungsregressionen und der Client-Produktionsbuild sind erfolgreich. | +| 2026-09-10 | Result-DB-Darstellung fuer angereicherte Defender-CVEs korrigiert: Ist die normalisierte NVD-Beschreibung bis auf Leerraum identisch mit der bereits sichtbaren Scannerbeschreibung, wird sie im NVD-Abschnitt nicht erneut gerendert. Inhaltlich abweichende NVD-Beschreibungen bleiben als getrennte Quelle sichtbar; gespeicherte Scanner- und NVD-Daten werden nicht veraendert. Zwei fokussierte Regressionstests und der Client-Produktionsbuild sind erfolgreich. Keine API-, Schema- oder Persistenzaenderung. | +| 2026-09-10 | Separate globale CVE-Anreicherung nach dem Import umgesetzt: Das neue tenantunabhaengige CVE-Modell und Migration 021 speichern normalisierte NVD-Beschreibung, CVSS-Metriken, CWE, Referenzen, betroffene Konfigurationen, Zeitpunkte und Provenienz einmal je CVE. Local- und PostgreSQL-Repository, Assessment-/Export- und Unified-Result-Projektionen verwenden denselben Bestand fuer alle passenden Defender-Vorkommen. Die Same-Origin-API bietet GET sowie eine manuelle Enrich-/Refresh-Mutation; der Results-Browser zeigt und aktualisiert die Daten, ohne Scanner-Originalfelder zu veraendern. Der NVD-Client verwendet ausschliesslich den festen CVE-2.0-Endpunkt, akzeptiert optional `NVD_API_KEY` serverseitig und erzwingt Timeout und 8-MiB-Antwortgrenze. 45 fokussierte synthetische Tests, vollstaendiger `npm test`-Lauf und Client-/Server-Produktionsbuild erfolgreich. Nach erkannter Versionskollision mit den bereits angewandten fremden Ledger-Versionen 019 `native-policy-targets` und 020 `baseline-driven-policy-evaluation` wurde die neue Migration append-only auf die erste freie Version 021 gelegt, gegen die konfigurierte PostgreSQL-Instanz erfolgreich angewendet und der Backendstart per HTTP-200-Healthsignal verifiziert. Kein realer Mandanteninhalt und kein Live-NVD-Abruf. Phasen 9, 11 und 13A bleiben abgeschlossen. | +| 2026-09-10 | Defender-Betroffenheitszahl von der Rolle `score` auf `asset` umgestellt: In ASSESSOR ist `score` die Risikomagnitude (PingCastle bildet darauf seine Points ab und leitet daraus sogar die Severity ab), waehrend die Anzahl betroffener Devices eine Verbreitungskennzahl ist. Das aggregierte CVE traegt die Anzahl daher als `affectedAsset`; der Results-Browser zeigt sie unter der bestehenden Betroffenheitszeile, und `score` bleibt fuer echte Risikoscores frei. Kein Vergleichsschluessel betroffen (weiter `rule-id`). Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. | +| 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl (aus dem Score Affected Device Count) statt eines leeren `Affected Object`, da die CVE keinen einzelnen Asset mehr traegt. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. | +| 2026-09-10 | Defender-Ergebnisfenster auf eine Device-Tabelle umgestellt: Statt der rohen `Source Observations`-JSON zeigt der Result-Button einer CVE nun eine `Affected Devices`-Tabelle (Device, Scanner, Site, eine Zeile je Device), die der Extractor als Markdown-Tabelle liefert und das Ergebnisfenster ueber `RichTextValue` auswertet. Die frueheren Einzelfelder Affected Devices, Reporting Sensors und Site entfallen im Finding (kein Asset-/Kategorie-Mapping mehr; Vergleich weiter ueber `rule-id`), sodass sich Popup und Lesespalte nicht mehr doppeln. Die `Source Observations` bleiben als maschinenlesbare Evidenz erhalten und werden in der Lesespalte ausgeblendet; die Defender-Sonderansicht rekonstruiert ihr Modell unveraendert daraus. Fokussierte Defender-Import-, Dashboard- und Titeltests sowie der Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine SQL- oder Persistenzmigration; Profilversion 1.0.0 bleibt vor Erstfreigabe unveraendert; Phase 13A bleibt abgeschlossen. | +| 2026-09-10 | Phase-13A-Defender-Projektion auf CVE-Aggregation umgestellt: Der Source Extractor 1.0.0 emittiert nun genau ein Finding je CVE statt je Device-mal-CVE-Vorkommen, sodass Work eine CVE nicht mehr je betroffenem Device dupliziert. Die Devices werden im neuen Feld `Affected Devices` samt Anzahl zusammengefasst, jede Sensorbeobachtung aller Devices bleibt als strukturierte Evidenz erhalten, das Profil mappt `Affected Devices` als Asset und `Affected Device Count` als Score, und der Vergleichsschluessel ist auf `rule-id` (CVE) reduziert. Die Defender-Sonderansicht rekonstruiert das vollstaendige Device-mal-CVE-Modell weiterhin aus den erhaltenen Beobachtungen. Fokussierte Defender-Import-, Dashboard- und Titeltests sowie der Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine SQL- oder Persistenzmigration; Profilversion 1.0.0 bleibt vor Erstfreigabe unveraendert; Phase 13A bleibt abgeschlossen. | | 2026-09-10 | Phasen 7/13A fuer gemischte Defender-Timeslots korrigiert: Work bestimmt den Titel jedes Datensatzes anhand seiner Scannerprovenienz und des deklarativen Titel-Headers, sodass Defender-Vorkommen im gemeinsamen All-Records-Blatt die CVE statt Device-Name oder IP zeigen. Client Info behaelt bei mehreren Scannern die allgemeine Uebersicht und bietet Defender zusaetzlich als eigenen Tab mit den vorhandenen CVE-, Device- und Site-Sichten an. 19 fokussierte Dashboard-, Titel- und Graphregressionen erfolgreich. Keine API-, SQL-, Profil- oder Persistenzmigration; Phasen 7 und 13A bleiben abgeschlossen. | | 2026-09-10 | Phase-13A-Verarbeitungsvertrag fuer Defender-Importe korrigiert: Die Kompatibilitaetspruefung verwendet nun den tatsaechlich persistierten `processedWith.profileId` statt einer eventuell abweichenden erkannten Werkzeug-ID. Dadurch werden registrierte Defender-Profile 1.0.0 als aktuell bewertet; unbekannte Profil-/Versionspaare bleiben fail closed. Die Korrektur ist read-only fuer vorhandene Importdaten und benoetigt keinen Reimport oder Datenbankeingriff. 17 fokussierte Import-/Kompatibilitaetstests sowie der Gesamtlauf mit 766 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich; Phase 13A bleibt abgeschlossen. | | 2026-09-10 | Microsoft Defender for IoT vulnerability imports and views umgesetzt: Profil und Source Extractor 1.0.0 erweitern die generische Excel-/CSV-Pipeline; Device-Identitaeten, CVE-Definitionen, Device-mal-CVE-Vorkommen, rohe Sensorbeobachtungen und Aggregate-only-Zahlen bleiben getrennt. Client Info bietet CVE-, Device- und Site-Sichten mit Evidenz- und Reconciliation-Drill-down. Summary-only-Quellen bleiben ohne erfundene Findings viewable. 33 fokussierte synthetische Tests sowie 764 Standardtests, 8 uebersprungene optionale PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzmigration; Phasen 6, 7 und 13A bleiben abgeschlossen. | diff --git a/src/domain/processing-compatibility.ts b/src/domain/processing-compatibility.ts index 2f25020..1050256 100644 --- a/src/domain/processing-compatibility.ts +++ b/src/domain/processing-compatibility.ts @@ -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.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.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.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 }, diff --git a/src/domain/result-db-supplemental-disclosure.ts b/src/domain/result-db-supplemental-disclosure.ts index d4ba6b2..f323e6a 100644 --- a/src/domain/result-db-supplemental-disclosure.ts +++ b/src/domain/result-db-supplemental-disclosure.ts @@ -98,7 +98,46 @@ const pingCastleRules: DisclosureRule[] = [ toolId: 'pingcastle', property: 'kerberos.krbtgt.version', title: 'krbtgt version', subjectType: 'active-directory-environment', disclosureClass: 'technical-metadata', 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( diff --git a/src/exports/result-db-copy.ts b/src/exports/result-db-copy.ts index ba1490e..48d00a1 100644 --- a/src/exports/result-db-copy.ts +++ b/src/exports/result-db-copy.ts @@ -7,7 +7,34 @@ const SAFE_ENVIRONMENT_PROPERTIES = new Set([ 'forest.functional_level', 'logging.wef.configuration_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 { @@ -67,24 +94,21 @@ function groupedEnvironmentFacts(items: UnifiedResultProjection[]) { title: string; property: string; value: unknown; - state: string; tool: string; - count: number; }>(); items.forEach(item => { const property = copiedEnvironmentProperty(item); 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 = { title: privacySafeText(item.title), property, value: item.environmentNormalizedValue ?? null, - state: item.environmentState ?? 'unknown', tool: item.sourceTool ?? 'unknown' }; - const key = JSON.stringify(fact); - const existing = grouped.get(key); - if (existing) existing.count += 1; - else grouped.set(key, { ...fact, count: 1 }); + grouped.set(JSON.stringify(fact), fact); }); return [...grouped.values()]; } diff --git a/src/importers/pingcastle-environment-aggregates.ts b/src/importers/pingcastle-environment-aggregates.ts new file mode 100644 index 0000000..f89a76e --- /dev/null +++ b/src/importers/pingcastle-environment-aggregates.ts @@ -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; +} diff --git a/src/importers/pingcastle-source-extractor.ts b/src/importers/pingcastle-source-extractor.ts index f4e7c29..e784bdd 100644 --- a/src/importers/pingcastle-source-extractor.ts +++ b/src/importers/pingcastle-source-extractor.ts @@ -18,6 +18,7 @@ import { protectHtmlAttributeMarkup } from './html-attribute-markup'; import { htmlAttributeValue as attributeValue } from './html-attributes'; import { indexHtmlElements, type HtmlElementIndex } from './html-element-index'; import { acceptPingCastleRuleId, pingCastleHtmlRuleCoverage } from './pingcastle-rule-validation'; +import { derivePingCastleEnvironmentAggregates } from './pingcastle-environment-aggregates'; const maximumRuleCandidates = 5_000; const maximumFieldMarkupCharacters = 1_000_000; @@ -1027,6 +1028,7 @@ function contextObservations( source: { fileName: document.file.name, objectPath: 'html/facts/entra-configuration' } }); } + observations.push(...derivePingCastleEnvironmentAggregates(observations)); return observations; } diff --git a/src/profiles/pingcastle/pingcastle-definition.ts b/src/profiles/pingcastle/pingcastle-definition.ts index 70e8cd8..55d3ccd 100644 --- a/src/profiles/pingcastle/pingcastle-definition.ts +++ b/src/profiles/pingcastle/pingcastle-definition.ts @@ -47,11 +47,11 @@ export const pingCastleDefinition: ToolProfileDefinition = { diagnosticPolicy: 'fail-closed-by-impact' }, name: 'PingCastle', - version: '1.5.0', + version: '1.6.0', processing: { 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'] + reimportRequired: true, affectedDimensions: ['environment-context'] }, sourceSignals: [ { scope: 'file-name', pattern: /ping[\s_.-]*castle|^ad[_-]hc[_-]/i, contribution: 0.5, message: 'Der Dateiname entspricht einem PingCastle-Healthcheck.' }, diff --git a/tests/domain/processing-compatibility.test.ts b/tests/domain/processing-compatibility.test.ts index 4046114..5c3d972 100644 --- a/tests/domain/processing-compatibility.test.ts +++ b/tests/domain/processing-compatibility.test.ts @@ -29,7 +29,7 @@ describe('processing compatibility contracts', () => { const old = file('old', 'pingcastle', '1.1.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 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', contextless.processedWith)).toBe('pingcastle-canonical-v1.1'); diff --git a/tests/domain/result-db-supplemental-disclosure.test.ts b/tests/domain/result-db-supplemental-disclosure.test.ts index 8d1ed8b..8ea5737 100644 --- a/tests/domain/result-db-supplemental-disclosure.test.ts +++ b/tests/domain/result-db-supplemental-disclosure.test.ts @@ -69,4 +69,31 @@ describe('Result DB supplemental disclosure boundary', () => { property: 'risk.domain', normalizedValue: 101 }))).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(); + }); }); diff --git a/tests/exports/result-db-copy.test.ts b/tests/exports/result-db-copy.test.ts index 813dc43..1aebaf7 100644 --- a/tests/exports/result-db-copy.test.ts +++ b/tests/exports/result-db-copy.test.ts @@ -73,8 +73,43 @@ describe('Result DB compact privacy-safe JSON export', () => { expect(copied.summary).toMatchObject({ totalResults: 3, findingResults: 0, environmentResults: 3 }); expect(copied.environmentFacts).toEqual([{ 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/); }); + + 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/); + }); }); diff --git a/tests/features/datahub-processing-readiness.test.ts b/tests/features/datahub-processing-readiness.test.ts index aca062b..bb57bb3 100644 --- a/tests/features/datahub-processing-readiness.test.ts +++ b/tests/features/datahub-processing-readiness.test.ts @@ -145,7 +145,7 @@ describe('Datahub client processing readiness', () => { file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1') ])).toMatchObject({ 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.' }); }); @@ -154,7 +154,7 @@ describe('Datahub client processing readiness', () => { file('run-contextless', 'pingcastle', '1.2.0', 'pingcastle-canonical-v1.1') ])).toMatchObject({ 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.' }); }); diff --git a/tests/importers/pingcastle-environment-aggregates.test.ts b/tests/importers/pingcastle-environment-aggregates.test.ts new file mode 100644 index 0000000..17fcb07 --- /dev/null +++ b/tests/importers/pingcastle-environment-aggregates.test.ts @@ -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); + }); +}); diff --git a/tests/importers/pingcastle-source-extractor.test.ts b/tests/importers/pingcastle-source-extractor.test.ts index 8246776..52ace2e 100644 --- a/tests/importers/pingcastle-source-extractor.test.ts +++ b/tests/importers/pingcastle-source-extractor.test.ts @@ -91,7 +91,7 @@ describe('PingCastle native finding extraction', () => { expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0); expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0); expect(model.importFiles[0].processedWith).toMatchObject({ - profileId: 'pingcastle', profileVersion: '1.5.0', mappingVersion: '1.2.0', + profileId: 'pingcastle', profileVersion: '1.6.0', mappingVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1' }); expect(model.scanRuns[0].tool.version).toBe('3.4.0'); diff --git a/tests/persistence/unified-result.test.ts b/tests/persistence/unified-result.test.ts index c892bf4..6745d5e 100644 --- a/tests/persistence/unified-result.test.ts +++ b/tests/persistence/unified-result.test.ts @@ -261,7 +261,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.6.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1' } @@ -303,7 +303,7 @@ describe('Unified result read model', () => { resultProjectionId: 'environment-observation:environment-dfl', sourceKind: 'environment-observation', sourceTool: 'pingcastle', - sourceProfileVersion: '1.5.0', + sourceProfileVersion: '1.6.0', mappingRuleVersion: '1.2.0+context-1.0.0', processingStatus: 'current', importArtifactId: importFile.id,