feat(pingcastle): Update PingCastle environment aggregates and configuration disclosures
This commit is contained in:
parent
f651e57796
commit
48a1888b52
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue