diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index ac9a157..cf7d0b9 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. | 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. | @@ -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. | 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. | @@ -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. | 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 | 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. | 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 d12552b..069ee99 100644 --- a/src/profiles/pingcastle/pingcastle-definition.ts +++ b/src/profiles/pingcastle/pingcastle-definition.ts @@ -26,11 +26,11 @@ export const pingCastleDefinition: ToolProfileDefinition = { targets: ['domain.fqdn'], resultPopulation: 'findings-only' }, 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 7cc55ad..4ede742 100644 --- a/tests/features/datahub-processing-readiness.test.ts +++ b/tests/features/datahub-processing-readiness.test.ts @@ -87,7 +87,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.' }); }); @@ -96,7 +96,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 b3cf26c..bde87f5 100644 --- a/tests/importers/pingcastle-source-extractor.test.ts +++ b/tests/importers/pingcastle-source-extractor.test.ts @@ -90,7 +90,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,