2.9.0: privacy-compliant Result DB, materialized rows and disclosure policy 3.4.0
- Result DB is checked and stored on creation (migration 024, assessor_result_db_rows) with a content-free per-row trace, per-client identity guard and Open original in Work - Disclosure policy 3.4.0: review state no longer revealed via withheld-field trace, per-finding scanner scores withheld, CIS numbers keep all seven levels - Less over-redaction: "Limited" is no legal form, attached versions (Tls1.2, v1.0), Windows 95, 2012-R2, step/option/page references, documented Microsoft GUIDs and documented vendor values in the recommendation field stay - Maester first read shows the native result statement verbatim Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8f131d6da
commit
0fe283d68f
File diff suppressed because one or more lines are too long
|
|
@ -77,6 +77,13 @@ ein Rollback ohne Teilimport.
|
||||||
Migration `002_import_fingerprints.sql` ergänzt `assessor_import_fingerprints`. Der SHA-256-Wert ist Primärschlüssel; gespeichert werden außerdem Dateiname, Größe, letzter Dateiänderungszeitpunkt, erster und letzter Importzeitpunkt sowie ein Wiederholungszähler.
|
Migration `002_import_fingerprints.sql` ergänzt `assessor_import_fingerprints`. Der SHA-256-Wert ist Primärschlüssel; gespeichert werden außerdem Dateiname, Größe, letzter Dateiänderungszeitpunkt, erster und letzter Importzeitpunkt sowie ein Wiederholungszähler.
|
||||||
|
|
||||||
Der Abschluss eines bestätigten Batchimports verwendet `commitCanonicalImport`. Der PostgreSQL-Adapter schreibt neues Projekt, Assessment, Scan-Lauf, kanonische Importdaten und Fingerprints auf derselben Poolverbindung innerhalb derselben Transaktion. Die Repository-Revision wird dabei genau einmal erhöht. Ein Validierungs-, Auswahl- oder Datenbankfehler erzeugt ein vollständiges Rollback.
|
Der Abschluss eines bestätigten Batchimports verwendet `commitCanonicalImport`. Der PostgreSQL-Adapter schreibt neues Projekt, Assessment, Scan-Lauf, kanonische Importdaten und Fingerprints auf derselben Poolverbindung innerhalb derselben Transaktion. Die Repository-Revision wird dabei genau einmal erhöht. Ein Validierungs-, Auswahl- oder Datenbankfehler erzeugt ein vollständiges Rollback.
|
||||||
|
## Abgeleitete Result DB
|
||||||
|
|
||||||
|
`assessor_result_db_rows` und `assessor_result_db_state` (Migration 024) sind abgeleitete Daten: Das
|
||||||
|
Backend baut sie unter einer Advisory-Sperre aus den kanonischen Tabellen neu auf, sobald sich die
|
||||||
|
Repository-Revision oder eine Policy-/Waechterversion aendert. Sie werden nie von Hand bearbeitet und
|
||||||
|
zaehlen die Repository-Revision nicht hoch.
|
||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
Vor Migrationen, Updates und größeren Löschvorgängen ist ein logisches Backup
|
Vor Migrationen, Updates und größeren Löschvorgängen ist ein logisches Backup
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,63 @@ und Artefaktidentitaeten sowie Zeitpunkte. Manifest-Scope und Filter werden nur
|
||||||
als Boolesche beziehungsweise Mengenangaben ausgegeben; Suchtext, Assetfilter
|
als Boolesche beziehungsweise Mengenangaben ausgegeben; Suchtext, Assetfilter
|
||||||
und konkrete Scope-IDs werden nie serialisiert.
|
und konkrete Scope-IDs werden nie serialisiert.
|
||||||
|
|
||||||
|
Disclosure Policy 2.6.0 (2026-09-28) schliesst eine Luecke zur obigen Grenze:
|
||||||
|
`reviewStatus`, `reviewDisposition` (einschliesslich akzeptierter Risiken) und
|
||||||
|
`reviewPriority` gelangten zuvor in Result DB API und JSONL. Die Projektion gibt
|
||||||
|
jetzt nur noch Felder aus, die in `RESULT_DB_FIELD_DISCLOSURE` ausdruecklich als
|
||||||
|
`disclose` klassifiziert sind; ein neues Projektionsfeld verhindert die
|
||||||
|
Kompilierung, bis es klassifiziert ist, und zur Laufzeit hinzugefuegte Felder
|
||||||
|
fallen weg. Review-Status und -Disposition sind deshalb keine Result-DB-Filter
|
||||||
|
mehr; Work und die interne Analyse behalten den Review unveraendert. JSONL-Schema
|
||||||
|
2.1.0 ergaenzt im Manifest `handling` mit der Klassifikation
|
||||||
|
`confidential-security-posture`: Die Result DB ist anonymisiert, beschreibt aber
|
||||||
|
weiterhin ausnutzbare Schwaechen der bewerteten Umgebung und ist bei Weitergabe
|
||||||
|
an externe LLM-Anbieter oder Dritte entsprechend zu behandeln.
|
||||||
|
|
||||||
|
Disclosure Policy 3.0.0 und JSONL-Schema 3.0.0 (2026-09-28, verbindliche
|
||||||
|
Nutzeranforderung, ersetzt die Entscheidung vom 2026-09-25 zum vollstaendigen
|
||||||
|
Ist-/Sollwert): Die Result DB ist datenschutzkonform und keine Angriffsanleitung.
|
||||||
|
Sie enthaelt keine Werte, die die bewertete Umgebung wiedererkennbar machen oder
|
||||||
|
den Angriffsaufwand kalibrieren. Der GPO-Istwert samt Principals, die Anzahl der
|
||||||
|
GPO-Quellen und konkrete Baseline-Releases entfallen; es bleiben technische
|
||||||
|
Setting-ID, Sollwert, Comparator-Relation und die Baseline-Familie. Identische
|
||||||
|
Baseline-Erwartungen werden zusammengefasst. In Scannerprosa ausser CIS-CAT
|
||||||
|
werden exakte Anzahlen, Datums-, Zeit- und Dauerangaben zu `[count]`, `[date]`,
|
||||||
|
`[time]` und `[duration]`; oeffentliche technische Kennungen (Versionen, CVE/KB,
|
||||||
|
Regel- und ATT&CK-IDs, Produktnamen, Ports, Event-IDs, oeffentliche SIDs) bleiben.
|
||||||
|
CIS-CAT-Prosa ist Benchmarktext und behaelt ihre oeffentlichen Empfehlungswerte.
|
||||||
|
Die Zusatzbeobachtungs-Policy 2.0.0 laesst nur noch binaere Konfigurationszustaende
|
||||||
|
zu; Anzahlen, Scores, Funktionsebenen und Versionen bleiben intern. Das JSONL-Manifest
|
||||||
|
enthaelt statt `generatedAt` nur `generatedOn` (Kalendertag), der Dateiname nur das
|
||||||
|
Datum. Work und interne Analyse behalten alle Originalwerte.
|
||||||
|
|
||||||
|
Disclosure Policy 3.1.0 (2026-09-28, Nutzerkorrektur nach Pruefung des Dummy-Exports)
|
||||||
|
begrenzt die Schwaerzung auf echte Umgebungswerte: Nummerierte Schritte bleiben; nur das
|
||||||
|
Empfehlungsfeld behaelt Hersteller-Schwellenwerte (`at least 8 characters`, `more than 180 days`,
|
||||||
|
`15 or more`), Titel und Beschreibung bleiben vollstaendig geschwaerzt. Gruppenrichtlinien- und
|
||||||
|
Registry-Pfade sowie dokumentierte Windows-Autoritaeten (`NT SERVICE`, `RESTRICTED SERVICES` u. a.)
|
||||||
|
gelten nicht als Konten. `[ip]` betrifft nur alleinstehende IPv4-Adressen, nie CIS-Regelnummern,
|
||||||
|
`Rule`-Verweise oder dokumentierte Bereichsgrenzen. Der Handling-Hinweis nennt jetzt ausdruecklich,
|
||||||
|
dass oeffentliche Baseline-Erwartungen, Herstellerhinweise und Schwellenwerte erhalten bleiben.
|
||||||
|
|
||||||
|
Disclosure Policy 3.2.0 (2026-09-28, Nutzerkorrektur nach Pruefung des gefilterten Dummy-Exports)
|
||||||
|
erhaelt Inline-Aufzaehlungen (Folge ab 1 mit mindestens zwei Punkten, eine wiederholte oder
|
||||||
|
uebersprungene Quellnummer toleriert), dokumentierte Attributflags (`adminCount=1`) und im
|
||||||
|
Empfehlungsfeld Zielwerte (`set to 1`). Links und Hostnamen von Hersteller- und Standard-
|
||||||
|
dokumentation bleiben, solange sie weder `@`, Tenant-Merkmale (`onmicrosoft`, `sharepoint`),
|
||||||
|
private Literale noch weitere nicht oeffentliche Hosts enthalten. Tenant-Hosts und alle
|
||||||
|
uebrigen Hosts bleiben geschwaerzt. Dateinamen, ScubaGear-Kennungen, Graph-Berechtigungen,
|
||||||
|
Code-Namespaces und -Bezeichner sowie Satzverbindungen ohne Leerzeichen gelten nicht als Domain.
|
||||||
|
|
||||||
|
Gespeicherte Result DB (2026-09-28, verbindliche Nutzeranforderung, Migration 024): Die Zeilen werden beim
|
||||||
|
Erzeugen offengelegt, geprueft und in `assessor_result_db_rows` gespeichert, mit Statussatz
|
||||||
|
`assessor_result_db_state` (Revision, Policy- und Waechterversion, Zeilen, Ersetzungen, gesperrte Zeilen).
|
||||||
|
Der Aufbau laeuft nach dem Start und nach jeder gespeicherten Aenderung; Lesezugriffe warten auf einen
|
||||||
|
aktuellen Stand. Jede Zeile traegt `disclosureTrace` (Feld, Regel, Anzahl, zurueckgehaltene Felder) ohne
|
||||||
|
Originalwerte. Der Identitaetswaechter 1.3.0 entfernt die eigenen Namen des Clients aus allen Zeilen und
|
||||||
|
sperrt ein Feld, wenn ein Name in anderer Schreibweise ueberlebt. `GET /api/v1/results/status` liefert den
|
||||||
|
Pruefstand; die Oberflaeche zeigt ihn und im Zeilendetail das Protokoll mit `Open original in Work`.
|
||||||
|
|
||||||
### Asset-/Kantenschicht: Struktur ohne Producer
|
### Asset-/Kantenschicht: Struktur ohne Producer
|
||||||
|
|
||||||
`CANONICAL_ASSET`, `ASSET_EDGE`, `ASSET_PROPERTY` und `EDGE_COVERAGE` wurden
|
`CANONICAL_ASSET`, `ASSET_EDGE`, `ASSET_PROPERTY` und `EDGE_COVERAGE` wurden
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.8.7",
|
"version": "2.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.8.7",
|
"version": "2.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.8.7",
|
"version": "2.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -987,6 +987,9 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
||||||
if (segments.length === 1 && segments[0] === 'results' && method === 'GET') {
|
if (segments.length === 1 && segments[0] === 'results' && method === 'GET') {
|
||||||
status = 200; sendJson(response, status, await repository.queryUnifiedResults(unifiedQueryFrom(url)), requestId); return;
|
status = 200; sendJson(response, status, await repository.queryUnifiedResults(unifiedQueryFrom(url)), requestId); return;
|
||||||
}
|
}
|
||||||
|
if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'status' && method === 'GET') {
|
||||||
|
status = 200; sendJson(response, status, await repository.getResultDbStatus(), requestId); return;
|
||||||
|
}
|
||||||
if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'facets' && method === 'GET') {
|
if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'facets' && method === 'GET') {
|
||||||
status = 200; sendJson(response, status, await repository.queryUnifiedResultFacets(unifiedQueryFrom(url)), requestId); return;
|
status = 200; sendJson(response, status, await repository.queryUnifiedResultFacets(unifiedQueryFrom(url)), requestId); return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import scannerEnvironmentContext from './migrations/018_scanner_environment_cont
|
||||||
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
|
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
|
||||||
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
|
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
|
||||||
import referenceRecognition from './migrations/023_reference_recognition.sql?raw';
|
import referenceRecognition from './migrations/023_reference_recognition.sql?raw';
|
||||||
|
import resultDbMaterialization from './migrations/024_result_db_materialization.sql?raw';
|
||||||
|
|
||||||
export interface DatabaseMigration {
|
export interface DatabaseMigration {
|
||||||
version: string;
|
version: string;
|
||||||
|
|
@ -47,5 +48,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||||
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
|
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
|
||||||
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
|
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
|
||||||
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation },
|
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation },
|
||||||
{ version: '023', name: 'reference-recognition', sql: referenceRecognition }
|
{ version: '023', name: 'reference-recognition', sql: referenceRecognition },
|
||||||
|
{ version: '024', name: 'result-db-materialization', sql: resultDbMaterialization }
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- Materialized Result DB (user decision 2026-09-28): every row is disclosed, checked and stored when
|
||||||
|
-- its source data changes, so UI, API and JSONL export read exactly the same verified content.
|
||||||
|
-- Derived data only: rows are rebuilt from canonical tables and never edited by hand.
|
||||||
|
CREATE TABLE assessor_result_db_rows (
|
||||||
|
result_projection_id text PRIMARY KEY CHECK (btrim(result_projection_id) <> ''),
|
||||||
|
ordinal integer NOT NULL CHECK (ordinal >= 0),
|
||||||
|
project_id text NOT NULL REFERENCES assessor_projects(id) ON DELETE CASCADE,
|
||||||
|
source_kind text NOT NULL CHECK (btrim(source_kind) <> ''),
|
||||||
|
row_data jsonb NOT NULL CHECK (jsonb_typeof(row_data) = 'object'),
|
||||||
|
guard_withheld boolean NOT NULL DEFAULT false,
|
||||||
|
source_revision bigint NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX assessor_result_db_rows_project_idx ON assessor_result_db_rows(project_id, ordinal);
|
||||||
|
CREATE INDEX assessor_result_db_rows_ordinal_idx ON assessor_result_db_rows(ordinal);
|
||||||
|
|
||||||
|
-- One state record describes the stored Result DB as a whole.
|
||||||
|
CREATE TABLE assessor_result_db_state (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||||
|
source_revision bigint NOT NULL,
|
||||||
|
disclosure_policy_version text NOT NULL,
|
||||||
|
identity_guard_version text NOT NULL,
|
||||||
|
row_count integer NOT NULL CHECK (row_count >= 0),
|
||||||
|
redaction_count integer NOT NULL CHECK (redaction_count >= 0),
|
||||||
|
guard_withheld_row_count integer NOT NULL CHECK (guard_withheld_row_count >= 0),
|
||||||
|
materialized_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -55,7 +55,12 @@ import {
|
||||||
type UnifiedResultQuery,
|
type UnifiedResultQuery,
|
||||||
type UnifiedResultQueryResult
|
type UnifiedResultQueryResult
|
||||||
} from '../../src/domain/unified-result';
|
} from '../../src/domain/unified-result';
|
||||||
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
|
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
|
||||||
|
import { buildResultDbIdentityGuards } from '../../src/domain/result-db-identity-guard';
|
||||||
|
import { isResultDbCurrent, materializeResultDb, type ResultDbMaterializationStatus } from '../../src/domain/result-db-materialization';
|
||||||
|
|
||||||
|
/** Advisory lock serializing Result DB materialization across backend processes. */
|
||||||
|
const RESULT_DB_MATERIALIZATION_LOCK = 420_260_928;
|
||||||
import { createPolicyConsistencyUnifiedResults } from '../../src/evaluation/policy-consistency-results';
|
import { createPolicyConsistencyUnifiedResults } from '../../src/evaluation/policy-consistency-results';
|
||||||
import { deriveExposureState, deriveIsIoe, deriveResultType } from '../../src/domain/result-contract-mapping';
|
import { deriveExposureState, deriveIsIoe, deriveResultType } from '../../src/domain/result-contract-mapping';
|
||||||
import { importArtifactValidationErrors, ingestionJobValidationErrors, processingRunValidationErrors } from '../../src/domain/ingestion-persistence';
|
import { importArtifactValidationErrors, ingestionJobValidationErrors, processingRunValidationErrors } from '../../src/domain/ingestion-persistence';
|
||||||
|
|
@ -394,7 +399,10 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
static async create(pool: Pool): Promise<PostgresAssessorRepository> {
|
static async create(pool: Pool): Promise<PostgresAssessorRepository> {
|
||||||
const result = await pool.query<RevisionRow>('SELECT revision FROM assessor_repository_state WHERE singleton = true');
|
const result = await pool.query<RevisionRow>('SELECT revision FROM assessor_repository_state WHERE singleton = true');
|
||||||
if (!result.rows[0]) throw new Error('assessor_repository_state fehlt; Datenbankmigrationen wurden nicht ausgeführt.');
|
if (!result.rows[0]) throw new Error('assessor_repository_state fehlt; Datenbankmigrationen wurden nicht ausgeführt.');
|
||||||
return new PostgresAssessorRepository(pool, Number(result.rows[0].revision));
|
const repository = new PostgresAssessorRepository(pool, Number(result.rows[0].revision));
|
||||||
|
// Build or refresh the stored Result DB right after start, not on the first read.
|
||||||
|
repository.scheduleResultDbRebuild();
|
||||||
|
return repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
get revision(): number {
|
get revision(): number {
|
||||||
|
|
@ -426,6 +434,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
this.cachedRevision = databaseRevision;
|
this.cachedRevision = databaseRevision;
|
||||||
}
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
if (changed(result)) this.scheduleResultDbRebuild();
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { await client.query('ROLLBACK'); } catch { /* connection failure keeps original error */ }
|
try { await client.query('ROLLBACK'); } catch { /* connection failure keeps original error */ }
|
||||||
|
|
@ -1225,20 +1234,152 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
|
private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultProjection[]> {
|
||||||
// Public tool identities are rebuilt by the Result DB disclosure policy.
|
// The Result DB is served only from the stored, disclosure-checked rows (see materializeResultDb).
|
||||||
// Do not prefilter on the untrusted source value before that mapping; the
|
if (query.audience !== 'internal-analysis') return this.storedResultDbRows(query.projectId);
|
||||||
// shared in-memory filter below applies the public value consistently.
|
|
||||||
const sourceQuery = query.audience === 'internal-analysis' ? query : { ...query, sourceTool: undefined };
|
|
||||||
const [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([
|
const [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([
|
||||||
this.unifiedScannerResults(sourceQuery),
|
this.unifiedScannerResults(query),
|
||||||
this.unifiedEnvironmentResults(sourceQuery),
|
this.unifiedEnvironmentResults(query),
|
||||||
this.unifiedPolicyObservationResults(sourceQuery),
|
this.unifiedPolicyObservationResults(query),
|
||||||
this.unifiedPolicyConsistencyResults(sourceQuery),
|
this.unifiedPolicyConsistencyResults(query),
|
||||||
this.unifiedPolicyEvaluationComparisonResults(sourceQuery),
|
this.unifiedPolicyEvaluationComparisonResults(query),
|
||||||
this.unifiedPolicyResults(sourceQuery)
|
this.unifiedPolicyResults(query)
|
||||||
]);
|
]);
|
||||||
const results = [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
|
return [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
|
||||||
return query.audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection);
|
}
|
||||||
|
|
||||||
|
private async databaseRevision(client: Pick<Pool, 'query'> = this.pool): Promise<number> {
|
||||||
|
const result = await client.query<RevisionRow>('SELECT revision FROM assessor_repository_state WHERE singleton = true');
|
||||||
|
return Number(result.rows[0]?.revision ?? -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storedResultDbState(client: Pick<Pool, 'query'> = this.pool): Promise<ResultDbMaterializationStatus | undefined> {
|
||||||
|
const result = await client.query<{
|
||||||
|
source_revision: string; disclosure_policy_version: string; identity_guard_version: string; row_count: number;
|
||||||
|
redaction_count: number; guard_withheld_row_count: number; materialized_at: Date;
|
||||||
|
} & QueryResultRow>('SELECT * FROM assessor_result_db_state WHERE singleton = true');
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return undefined;
|
||||||
|
return {
|
||||||
|
state: 'current', sourceRevision: Number(row.source_revision), repositoryRevision: Number(row.source_revision),
|
||||||
|
disclosurePolicyVersion: row.disclosure_policy_version, identityGuardVersion: row.identity_guard_version,
|
||||||
|
materializedAt: new Date(row.materialized_at).toISOString(), rowCount: row.row_count,
|
||||||
|
redactionCount: row.redaction_count, guardWithheldRowCount: row.guard_withheld_row_count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultDbBuild?: Promise<void>;
|
||||||
|
private resultDbRebuildTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
/** Called after every committed change: the Result DB is rebuilt shortly after, not on the next read. */
|
||||||
|
private scheduleResultDbRebuild(): void {
|
||||||
|
if (this.resultDbRebuildTimer) clearTimeout(this.resultDbRebuildTimer);
|
||||||
|
this.resultDbRebuildTimer = setTimeout(() => {
|
||||||
|
this.resultDbRebuildTimer = undefined;
|
||||||
|
if ((this.pool as Pool & { ending?: boolean }).ending) return;
|
||||||
|
void this.ensureResultDb().catch(error => console.warn('[assessor] Result DB rebuild failed; reads will retry.', error));
|
||||||
|
}, 1500);
|
||||||
|
this.resultDbRebuildTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guarantees that the stored Result DB matches the current repository revision and policy versions. */
|
||||||
|
private async ensureResultDb(): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
if (isResultDbCurrent(await this.storedResultDbState(), await this.databaseRevision())) return;
|
||||||
|
this.resultDbBuild ??= this.rebuildResultDb().finally(() => { this.resultDbBuild = undefined; });
|
||||||
|
await this.resultDbBuild;
|
||||||
|
}
|
||||||
|
if (!isResultDbCurrent(await this.storedResultDbState(), await this.databaseRevision())) {
|
||||||
|
throw new Error('The Result DB could not be brought up to date because the data kept changing. Please retry.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discloses, checks and stores every Result DB row in one transaction. The build reads the complete,
|
||||||
|
* unfiltered source data of all clients, so every client's identity guard is complete. Derived data:
|
||||||
|
* this does not advance the repository revision.
|
||||||
|
*/
|
||||||
|
private async rebuildResultDb(): Promise<void> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
let locked = false;
|
||||||
|
try {
|
||||||
|
await client.query('SELECT pg_advisory_lock($1)', [RESULT_DB_MATERIALIZATION_LOCK]);
|
||||||
|
locked = true;
|
||||||
|
const revision = await this.databaseRevision(client);
|
||||||
|
if (isResultDbCurrent(await this.storedResultDbState(client), revision)) return;
|
||||||
|
const query: UnifiedResultQuery = { audience: 'result-db' };
|
||||||
|
const [scanner, environment, policyConsistency, policyEvaluations, policy, policyObservations, projects, observations] = await Promise.all([
|
||||||
|
this.unifiedScannerResults(query),
|
||||||
|
this.unifiedEnvironmentResults(query),
|
||||||
|
this.unifiedPolicyConsistencyResults(query),
|
||||||
|
this.unifiedPolicyEvaluationComparisonResults(query),
|
||||||
|
this.unifiedPolicyResults(query),
|
||||||
|
this.currentPolicyObservationResults(),
|
||||||
|
this.pool.query<DataRow<AssessmentProject>>('SELECT data FROM assessor_projects'),
|
||||||
|
this.pool.query<{ project_id: string; observation: EnvironmentObservation } & QueryResultRow>(`
|
||||||
|
SELECT a.project_id, o.data AS observation
|
||||||
|
FROM assessor_environment_observations o
|
||||||
|
JOIN assessor_scan_runs r ON r.id = o.scan_run_id
|
||||||
|
JOIN assessor_assessments a ON a.id = r.assessment_id
|
||||||
|
`)
|
||||||
|
]);
|
||||||
|
const sources = [...scanner, ...environment, ...policyConsistency, ...policyEvaluations, ...policy];
|
||||||
|
const guards = buildResultDbIdentityGuards({
|
||||||
|
projects: projects.rows.map(row => row.data),
|
||||||
|
environmentObservations: observations.rows.map(row => ({ projectId: row.project_id, observation: row.observation })),
|
||||||
|
internalResults: [...sources, ...policyObservations]
|
||||||
|
});
|
||||||
|
const { rows, status } = materializeResultDb(sources, guards, revision, new Date().toISOString());
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('DELETE FROM assessor_result_db_rows');
|
||||||
|
for (let start = 0; start < rows.length; start += 500) {
|
||||||
|
const batch = rows.slice(start, start + 500).map((row, index) => ({
|
||||||
|
result_projection_id: row.resultProjectionId,
|
||||||
|
ordinal: start + index,
|
||||||
|
project_id: row.projectId,
|
||||||
|
source_kind: row.sourceKind,
|
||||||
|
row_data: row,
|
||||||
|
guard_withheld: Boolean(row.disclosureTrace?.guardWithheldFields.length),
|
||||||
|
source_revision: revision
|
||||||
|
}));
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO assessor_result_db_rows (result_projection_id, ordinal, project_id, source_kind, row_data, guard_withheld, source_revision)
|
||||||
|
SELECT result_projection_id, ordinal, project_id, source_kind, row_data, guard_withheld, source_revision
|
||||||
|
FROM jsonb_to_recordset($1::jsonb) AS row(result_projection_id text, ordinal integer, project_id text,
|
||||||
|
source_kind text, row_data jsonb, guard_withheld boolean, source_revision bigint)
|
||||||
|
`, [JSON.stringify(batch)]);
|
||||||
|
}
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO assessor_result_db_state (singleton, source_revision, disclosure_policy_version, identity_guard_version,
|
||||||
|
row_count, redaction_count, guard_withheld_row_count, materialized_at)
|
||||||
|
VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (singleton) DO UPDATE SET source_revision = EXCLUDED.source_revision,
|
||||||
|
disclosure_policy_version = EXCLUDED.disclosure_policy_version, identity_guard_version = EXCLUDED.identity_guard_version,
|
||||||
|
row_count = EXCLUDED.row_count, redaction_count = EXCLUDED.redaction_count,
|
||||||
|
guard_withheld_row_count = EXCLUDED.guard_withheld_row_count, materialized_at = EXCLUDED.materialized_at
|
||||||
|
`, [revision, status.disclosurePolicyVersion, status.identityGuardVersion, status.rowCount, status.redactionCount,
|
||||||
|
status.guardWithheldRowCount, status.materializedAt]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK').catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (locked) await client.query('SELECT pg_advisory_unlock($1)', [RESULT_DB_MATERIALIZATION_LOCK]).catch(() => undefined);
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storedResultDbRows(projectId?: string): Promise<UnifiedResultProjection[]> {
|
||||||
|
await this.ensureResultDb();
|
||||||
|
const result = await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>(projectId
|
||||||
|
? 'SELECT row_data FROM assessor_result_db_rows WHERE project_id = $1 ORDER BY ordinal'
|
||||||
|
: 'SELECT row_data FROM assessor_result_db_rows ORDER BY ordinal', projectId ? [projectId] : []);
|
||||||
|
return result.rows.map(row => row.row_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResultDbStatus(): Promise<ResultDbMaterializationStatus> {
|
||||||
|
const [state, revision] = await Promise.all([this.storedResultDbState(), this.databaseRevision()]);
|
||||||
|
if (!state) return { state: 'not-built', repositoryRevision: revision, rowCount: 0, redactionCount: 0, guardWithheldRowCount: 0 };
|
||||||
|
return { ...state, repositoryRevision: revision, state: isResultDbCurrent(state, revision) ? 'current' : 'outdated' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
||||||
|
|
@ -1253,27 +1394,10 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
|
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
|
||||||
const scannerPrefix = 'scanner-finding:';
|
await this.ensureResultDb();
|
||||||
const policyPrefix = 'policy-evaluation:';
|
const result = await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>(
|
||||||
const policyComparisonPrefix = 'policy-evaluation-comparison:';
|
'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1', [resultProjectionId]);
|
||||||
const policyObservationPrefix = 'policy-observation:';
|
return result.rows[0] ? removeResultDbScopeIdentifiers(result.rows[0].row_data) : null;
|
||||||
const policyConsistencyPrefix = 'policy-consistency:';
|
|
||||||
const environmentPrefix = 'environment-observation:';
|
|
||||||
const items = resultProjectionId.startsWith(scannerPrefix)
|
|
||||||
? await this.unifiedScannerResults({}, resultProjectionId.slice(scannerPrefix.length))
|
|
||||||
: resultProjectionId.startsWith(policyComparisonPrefix)
|
|
||||||
? await this.unifiedPolicyEvaluationComparisonResults({}, resultProjectionId.slice(policyComparisonPrefix.length))
|
|
||||||
: resultProjectionId.startsWith(policyPrefix)
|
|
||||||
? await this.unifiedPolicyResults({}, resultProjectionId.slice(policyPrefix.length))
|
|
||||||
: resultProjectionId.startsWith(policyObservationPrefix)
|
|
||||||
? await this.unifiedPolicyObservationResults({}, resultProjectionId.slice(policyObservationPrefix.length))
|
|
||||||
: resultProjectionId.startsWith(policyConsistencyPrefix)
|
|
||||||
? await this.unifiedPolicyConsistencyResults({})
|
|
||||||
: resultProjectionId.startsWith(environmentPrefix)
|
|
||||||
? await this.unifiedEnvironmentResults({}, resultProjectionId.slice(environmentPrefix.length))
|
|
||||||
: [];
|
|
||||||
const item = items.find(item => item.resultProjectionId === resultProjectionId);
|
|
||||||
return item ? removeResultDbScopeIdentifiers(discloseResultDbProjection(item)) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
|
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type { ImportedDataset } from '../domain/dataset';
|
||||||
import type { ReportProjection } from '../domain/report-projection';
|
import type { ReportProjection } from '../domain/report-projection';
|
||||||
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
|
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
|
||||||
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
|
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
|
||||||
|
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||||
import {
|
import {
|
||||||
ASSESSOR_API_PREFIX,
|
ASSESSOR_API_PREFIX,
|
||||||
ASSESSOR_MUTATION_HEADER,
|
ASSESSOR_MUTATION_HEADER,
|
||||||
|
|
@ -227,6 +228,10 @@ export class AssessorApiClient {
|
||||||
return this.request(unifiedQueryPath('/results/facets', query));
|
return this.request(unifiedQueryPath('/results/facets', query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getResultDbStatus(): Promise<ResultDbMaterializationStatus> {
|
||||||
|
return this.request('/results/status');
|
||||||
|
}
|
||||||
|
|
||||||
getTimeslotResultMetrics(projectId: string, scanRunIds: string[], assessmentIds: string[]): Promise<DatahubTimeslotResultMetrics | null> {
|
getTimeslotResultMetrics(projectId: string, scanRunIds: string[], assessmentIds: string[]): Promise<DatahubTimeslotResultMetrics | null> {
|
||||||
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/timeslot-result-metrics`,
|
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/timeslot-result-metrics`,
|
||||||
{ scanRunIds, assessmentIds }));
|
{ scanRunIds, assessmentIds }));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { DataHub } from '../features/project-import/DataHub';
|
||||||
import { DashboardPanel } from '../features/dashboard/DashboardPanel';
|
import { DashboardPanel } from '../features/dashboard/DashboardPanel';
|
||||||
import { DbStructurePanel } from '../features/db-structure/DbStructurePanel';
|
import { DbStructurePanel } from '../features/db-structure/DbStructurePanel';
|
||||||
import { ResultsBrowser } from '../features/results-browser/ResultsBrowser';
|
import { ResultsBrowser } from '../features/results-browser/ResultsBrowser';
|
||||||
|
import { workRecordIdsForResult } from '../features/results-browser/work-record-lookup';
|
||||||
import { SearchFilterBar, SearchTextControl, SheetAndSearchControls, WorkTimeslotControl } from '../features/search-filter/SearchFilterBar';
|
import { SearchFilterBar, SearchTextControl, SheetAndSearchControls, WorkTimeslotControl } from '../features/search-filter/SearchFilterBar';
|
||||||
import { useFilteredRecords } from '../features/search-filter/use-filtered-records';
|
import { useFilteredRecords } from '../features/search-filter/use-filtered-records';
|
||||||
import { DatasetViewer } from '../features/dataset-viewer/DatasetViewer';
|
import { DatasetViewer } from '../features/dataset-viewer/DatasetViewer';
|
||||||
|
|
@ -192,7 +193,9 @@ export function App() {
|
||||||
)}
|
)}
|
||||||
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={openWork}/>}
|
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={openWork}/>}
|
||||||
{activeArea === 'db-structure' && <DbStructurePanel backendStatus={backendStatus}/>}
|
{activeArea === 'db-structure' && <DbStructurePanel backendStatus={backendStatus}/>}
|
||||||
{activeArea === 'results' && <ResultsBrowser backendStatus={backendStatus} projectId={targetProjectId} scanRunIds={app.activeScanRunIds} assessmentIds={app.activeAssessmentIds}/>}
|
{activeArea === 'results' && <ResultsBrowser backendStatus={backendStatus} projectId={targetProjectId} scanRunIds={app.activeScanRunIds} assessmentIds={app.activeAssessmentIds}
|
||||||
|
workRecordIds={row => workRecordIdsForResult(dataset, row)}
|
||||||
|
onOpenInWork={recordIds => { app.setPathFindingIds(recordIds); openWork(); }}/>}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ import catalog from '../profiles/public-search/catalog.json';
|
||||||
import type { JsonValue } from './canonical-model';
|
import type { JsonValue } from './canonical-model';
|
||||||
import type { PolicyBaselineVariantAssessment } from './policy-evaluation';
|
import type { PolicyBaselineVariantAssessment } from './policy-evaluation';
|
||||||
import type { UnifiedResultCategory, UnifiedResultProjection } from './unified-result';
|
import type { UnifiedResultCategory, UnifiedResultProjection } from './unified-result';
|
||||||
|
import { wellKnownSidName } from '../shared/windows-codes';
|
||||||
|
import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard';
|
||||||
|
import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace';
|
||||||
|
|
||||||
export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '2.5.3';
|
export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.4.0';
|
||||||
|
|
||||||
const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z';
|
const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z';
|
||||||
|
|
||||||
|
|
@ -34,6 +37,17 @@ function disclosureTool(item: UnifiedResultProjection): { id: string; label: str
|
||||||
return safeTool(item.sourceTool);
|
return safeTool(item.sourceTool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documented Microsoft GUIDs (ASR rules, first-party application IDs, built-in role templates) are
|
||||||
|
* public guidance. Every other GUID can be a tenant, object or resource identifier and stays redacted.
|
||||||
|
*/
|
||||||
|
const publicGuids = new Set([...catalog.asrIds, ...catalog.microsoftIdentifiers].map(id => id.toLocaleLowerCase('en-US')));
|
||||||
|
const guidPattern = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu;
|
||||||
|
|
||||||
|
function isPublicGuid(value: string): boolean {
|
||||||
|
return publicGuids.has(value.toLocaleLowerCase('en-US'));
|
||||||
|
}
|
||||||
|
|
||||||
function exactCatalogId(values: readonly string[], value?: string): string | undefined {
|
function exactCatalogId(values: readonly string[], value?: string): string | undefined {
|
||||||
const folded = value?.trim().toLocaleLowerCase('en-US');
|
const folded = value?.trim().toLocaleLowerCase('en-US');
|
||||||
return folded && values.some(candidate => candidate.toLocaleLowerCase('en-US') === folded)
|
return folded && values.some(candidate => candidate.toLocaleLowerCase('en-US') === folded)
|
||||||
|
|
@ -54,7 +68,9 @@ function safePublicRuleId(item: UnifiedResultProjection): string | undefined {
|
||||||
return id && /^SI\d{3,12}$/i.test(id) ? id : undefined;
|
return id && /^SI\d{3,12}$/i.test(id) ? id : undefined;
|
||||||
}
|
}
|
||||||
if (scannerFamily !== 'cis-cat') return undefined;
|
if (scannerFamily !== 'cis-cat') return undefined;
|
||||||
const match = item.sourceRuleId?.match(/(?:^|[^0-9])([0-9]+(?:\.[0-9]+){1,5})(?:$|[^0-9])/);
|
// CIS recommendation numbers reach seven levels (18.10.42.11.1.1.1); a shorter cap truncates them
|
||||||
|
// into a sibling's number. A longer dotted run is not a recommendation number.
|
||||||
|
const match = item.sourceRuleId?.match(/(?:^|[^0-9.])([0-9]+(?:\.[0-9]+){1,7})(?:$|\.?[^0-9.])/);
|
||||||
return match?.[1];
|
return match?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +78,8 @@ const publicDomainNames = new Set([
|
||||||
'microsoft.com', 'windows.com', 'office.com', 'microsoftonline.com', 'azure.com', 'cisecurity.org'
|
'microsoft.com', 'windows.com', 'office.com', 'microsoftonline.com', 'azure.com', 'cisecurity.org'
|
||||||
]);
|
]);
|
||||||
const publicAccountPrefixes = /^(?:BUILTIN|NT AUTHORITY)\\/i;
|
const publicAccountPrefixes = /^(?:BUILTIN|NT AUTHORITY)\\/i;
|
||||||
|
/** Built-in Windows authorities; names below them are documented Microsoft principals, not customer accounts. */
|
||||||
|
const publicAccountAuthorities = /(?:^|[^\p{L}\p{N}])(?:BUILTIN|NT AUTHORITY|NT SERVICE|NT VIRTUAL MACHINE|RESTRICTED SERVICES|Window Manager|Font Driver Host)$/iu;
|
||||||
const publicDomainRoles = new Set([
|
const publicDomainRoles = new Set([
|
||||||
'domain admins', 'domain users', 'domain guests', 'domain computers', 'domain controllers',
|
'domain admins', 'domain users', 'domain guests', 'domain computers', 'domain controllers',
|
||||||
'cert publishers', 'schema admins', 'enterprise admins', 'group policy creator owners',
|
'cert publishers', 'schema admins', 'enterprise admins', 'group policy creator owners',
|
||||||
|
|
@ -107,9 +125,10 @@ class PolicyValueRedactor {
|
||||||
redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu,
|
redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu,
|
||||||
match => this.principalToken(match));
|
match => this.principalToken(match));
|
||||||
if (redactAccountNames) {
|
if (redactAccountNames) {
|
||||||
redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, match => {
|
redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, (match, offset: number, input: string) => {
|
||||||
if (publicAccountPrefixes.test(match)) return match;
|
|
||||||
const separator = match.indexOf('\\');
|
const separator = match.indexOf('\\');
|
||||||
|
if (publicAccountPrefixes.test(match)
|
||||||
|
|| publicAccountAuthorities.test(input.slice(0, offset) + match.slice(0, separator))) return match;
|
||||||
const domain = match.slice(0, separator);
|
const domain = match.slice(0, separator);
|
||||||
const principal = match.slice(separator + 1);
|
const principal = match.slice(separator + 1);
|
||||||
return publicDomainRoles.has(principal.toLocaleLowerCase('en-US'))
|
return publicDomainRoles.has(principal.toLocaleLowerCase('en-US'))
|
||||||
|
|
@ -120,7 +139,7 @@ class PolicyValueRedactor {
|
||||||
redacted = redacted
|
redacted = redacted
|
||||||
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[PATH]')
|
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[PATH]')
|
||||||
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[PATH]')
|
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[PATH]')
|
||||||
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[ID]')
|
.replace(guidPattern, match => isPublicGuid(match) ? match : '[ID]')
|
||||||
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match =>
|
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match =>
|
||||||
publicDomainNames.has(match.toLocaleLowerCase('en-US')) ? match : this.domainToken(match));
|
publicDomainNames.has(match.toLocaleLowerCase('en-US')) ? match : this.domainToken(match));
|
||||||
return redacted;
|
return redacted;
|
||||||
|
|
@ -153,11 +172,22 @@ function safePolicyNarrative(item: UnifiedResultProjection, redactor: PolicyValu
|
||||||
return result || undefined;
|
return result || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeBaselineLabel(item: UnifiedResultProjection, redactor: PolicyValueRedactor): string {
|
const baselineFamilies: Array<[RegExp, string]> = [
|
||||||
const label = item.policyBaselineLabel?.trim();
|
[/\bWindows\s+Server\b/iu, 'Microsoft Security Baseline - Windows Server'],
|
||||||
if (!label) return 'Baseline recommendation';
|
[/\bWindows\s+(?:1[01]|client)\b/iu, 'Microsoft Security Baseline - Windows Client'],
|
||||||
const publicPart = /\b(?:Microsoft|Windows|CIS|ANSSI|DISA|NCSC)\b.*$/iu.exec(label)?.[0];
|
[/\b(?:Microsoft\s+365|Office)\b/iu, 'Microsoft Security Baseline - Microsoft 365 Apps'],
|
||||||
return redactor.text(publicPart ?? 'Baseline recommendation');
|
[/\bEdge\b/iu, 'Microsoft Security Baseline - Microsoft Edge'],
|
||||||
|
[/\bCIS\b/iu, 'CIS Benchmark'],
|
||||||
|
[/\bMicrosoft\b/iu, 'Microsoft Security Baseline']
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the baseline family is disclosed. Concrete releases (for example Windows 11 25H2 or
|
||||||
|
* Windows Server 2025) would reveal the operating-system generations of the environment.
|
||||||
|
*/
|
||||||
|
function safeBaselineLabel(item: UnifiedResultProjection): string {
|
||||||
|
const label = item.policyBaselineLabel?.trim() ?? '';
|
||||||
|
return baselineFamilies.find(([pattern]) => pattern.test(label))?.[1] ?? 'Baseline recommendation';
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeBaselineVariantAssessment(
|
function safeBaselineVariantAssessment(
|
||||||
|
|
@ -165,32 +195,261 @@ function safeBaselineVariantAssessment(
|
||||||
redactor: PolicyValueRedactor
|
redactor: PolicyValueRedactor
|
||||||
): PolicyBaselineVariantAssessment | undefined {
|
): PolicyBaselineVariantAssessment | undefined {
|
||||||
if (!assessment) return undefined;
|
if (!assessment) return undefined;
|
||||||
|
// Customer values never leave internal analysis. Identical expectations are collapsed so the
|
||||||
|
// number of entries does not reveal how many baseline releases were compared.
|
||||||
|
const expectations = new Map<string, PolicyBaselineVariantAssessment['variants'][number]>();
|
||||||
|
for (const variant of assessment.variants) {
|
||||||
|
const baselineValues = redactor.json(variant.baselineValues as unknown as JsonValue) as unknown as typeof variant.baselineValues;
|
||||||
|
const comparator = {
|
||||||
|
version: variant.comparator.version,
|
||||||
|
comparator: variant.comparator.comparator,
|
||||||
|
relation: variant.comparator.relation,
|
||||||
|
baseline: redactor.json(variant.comparator.baseline as unknown as JsonValue) as typeof variant.comparator.baseline,
|
||||||
|
diagnosticCodes: controlledDiagnosticCodes(variant.comparator.diagnosticCodes) ?? []
|
||||||
|
};
|
||||||
|
const key = JSON.stringify([baselineValues, comparator]);
|
||||||
|
if (!expectations.has(key)) {
|
||||||
|
expectations.set(key, { settingId: `baseline-expectation-${expectations.size + 1}`, baselineValues, comparator });
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
scope: 'backup-configuration',
|
scope: 'backup-configuration',
|
||||||
decision: assessment.decision,
|
decision: assessment.decision,
|
||||||
actualValues: redactor.json(assessment.actualValues as unknown as JsonValue) as unknown as PolicyBaselineVariantAssessment['actualValues'],
|
actualValues: [],
|
||||||
variants: assessment.variants.map((variant, index) => ({
|
variants: [...expectations.values()],
|
||||||
settingId: `baseline-variant-${index + 1}`,
|
|
||||||
baselineValues: redactor.json(variant.baselineValues as unknown as JsonValue) as unknown as typeof variant.baselineValues,
|
|
||||||
comparator: {
|
|
||||||
...variant.comparator,
|
|
||||||
actual: redactor.json(variant.comparator.actual as unknown as JsonValue) as typeof variant.comparator.actual,
|
|
||||||
baseline: redactor.json(variant.comparator.baseline as unknown as JsonValue) as typeof variant.comparator.baseline,
|
|
||||||
diagnosticCodes: controlledDiagnosticCodes(variant.comparator.diagnosticCodes) ?? []
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
diagnosticCodes: controlledDiagnosticCodes(assessment.diagnosticCodes) ?? []
|
diagnosticCodes: controlledDiagnosticCodes(assessment.diagnosticCodes) ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']);
|
const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']);
|
||||||
const publicCisPolicyPathPairs = new Set([
|
|
||||||
'configuration\\administrative', 'templates\\system', 'sensors\\turn'
|
/**
|
||||||
|
* Tokens whose digits are public knowledge rather than facts about the assessed environment:
|
||||||
|
* SIDs (already reduced to public or placeholder form), dotted versions and benchmark numbers,
|
||||||
|
* vulnerability/update identifiers, rule and technique IDs, product names, ports and event IDs.
|
||||||
|
*/
|
||||||
|
const publicNumericTokens: RegExp[] = [
|
||||||
|
/\bS-1-\d+(?:-\d+)+\b/giu,
|
||||||
|
/\[[A-Z]+(?:_[A-Z]+)*_\d+\]/gu,
|
||||||
|
/\b(?:CVE|CWE|KB|MS)-?\d+(?:-\d+)*\b/giu,
|
||||||
|
/\bT\d{4}(?:\.\d{3})?\b/gu,
|
||||||
|
/\b[A-Z][A-Za-z]{0,9}[.-]\d+(?:[.-]\d+)*\b/gu,
|
||||||
|
/\b\d+(?:\.\d+)+\b/gu,
|
||||||
|
// Versions written directly after a name: `Tls1.2`, `Graph v1.0`, `SMBv3.1.1`.
|
||||||
|
/\b[A-Za-z]+\d+(?:\.\d+)+\b/gu,
|
||||||
|
/\b(?:Windows(?:\s+Server)?|Exchange(?:\s+Server)?|SQL\s+Server|Office|SharePoint)\s+(?:\d{4}(?:[- ]?R2)?|9[58]|1[01]|8\.1|[78])\b/giu,
|
||||||
|
// Server releases named without a product ("pre 2012-R2 schema").
|
||||||
|
/\b(?:19|20)\d{2}[- ]R2\b/gu,
|
||||||
|
// References to numbered parts of the same guidance or its source ("step 2", "Option 1", "Page 16").
|
||||||
|
/\b(?:steps?|options?|page)\s+\d{1,3}\b/giu,
|
||||||
|
/\b(?:Microsoft|Office|Windows|Dynamics)\s+365\b/giu,
|
||||||
|
// Documented attribute flags (`adminCount=1`, `adminCount attribute set to 1`) and CISA baseline sections.
|
||||||
|
/\b[a-z]+[A-Z][A-Za-z]*(?:\s+attribute\s+set\s+to\s+|\s*=\s*)\d+\b/gu,
|
||||||
|
/\bCISA\s+\d+(?=\.)/gu,
|
||||||
|
/\b(?:event(?:\s+id)?|port)\s+\d+\b/giu,
|
||||||
|
/\b\d+-bit\b/giu
|
||||||
|
];
|
||||||
|
|
||||||
|
function protectedTokenKey(index: number): string {
|
||||||
|
let result = '';
|
||||||
|
let rest = index;
|
||||||
|
do {
|
||||||
|
result = String.fromCharCode(97 + (rest % 26)) + result;
|
||||||
|
rest = Math.floor(rest / 26) - 1;
|
||||||
|
} while (rest >= 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact dates, durations and counts in scanner prose fingerprint the assessed environment and
|
||||||
|
* calibrate attack effort ("1 DC", "15 accounts", "55 days", "0 day(s) ago"). They become typed
|
||||||
|
* placeholders; the originals remain in internal analysis.
|
||||||
|
*/
|
||||||
|
/** Numbered steps ("1. Open …", "2) Select …") structure guidance; they are not observed quantities. */
|
||||||
|
const listMarker = /^[ \t]*\d{1,3}[.)](?=[ \t])/gmu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline ordered lists ("… center. 2. Select …", "Activate the rules. 2) Network …"). A marker counts
|
||||||
|
* only inside a run 1, 2, 3 … of at least two items with the same delimiter, so a lone count such as
|
||||||
|
* "set to 1. In addition" is not mistaken for a list.
|
||||||
|
*/
|
||||||
|
function inlineOrderedListMarkers(text: string): Array<[number, number]> {
|
||||||
|
const candidates = [...text.matchAll(/(?<![\p{L}\p{N}_.,])(\d{1,2})([.)])(?=[ \t]+\S)/gu)]
|
||||||
|
.map(match => ({ start: match.index, end: match.index + match[0].length, number: Number(match[1]), delimiter: match[2] }));
|
||||||
|
const accepted = new Set<number>();
|
||||||
|
candidates.forEach((candidate, index) => {
|
||||||
|
if (candidate.number !== 1 || accepted.has(index)) return;
|
||||||
|
const run = [index];
|
||||||
|
for (let next = index + 1, expected = 2; next < candidates.length; next += 1) {
|
||||||
|
if (candidates[next].delimiter !== candidate.delimiter) continue;
|
||||||
|
const number = candidates[next].number;
|
||||||
|
// Sources occasionally repeat or skip one step number ("3. … 3. … 5. …"); those stay in the list.
|
||||||
|
if (number === expected || number === expected + 1 || (number === expected - 1 && run.length > 1)) {
|
||||||
|
run.push(next);
|
||||||
|
expected = Math.max(expected, number + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (run.length >= 2) run.forEach(position => accepted.add(position));
|
||||||
|
});
|
||||||
|
return [...accepted].map(position => [candidates[position].start, candidates[position].end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationUnit = String.raw`(?:(?:second|minute|hour|day|week|month|year)(?:s|\(s\))?|characters?|character\(s\)|`
|
||||||
|
+ String.raw`Sekunden?|Minuten?|Stunden?|Tagen?|Tage?|Wochen?|Monaten?|Monate?|Jahren?|Jahre?|Zeichen)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommendation thresholds ("at least 14 characters", "more than 180 days", "15 or more") are
|
||||||
|
* vendor guidance, not facts about the environment. Only the recommendation field uses this
|
||||||
|
* exception; titles and descriptions carry scanner observations and stay fully redacted.
|
||||||
|
*/
|
||||||
|
const recommendationThresholds: RegExp[] = [
|
||||||
|
new RegExp(String.raw`\b(?:at (?:least|most)|minimum(?: of)?|maximum(?: of)?|no (?:more|less|fewer) than|(?:more|less|fewer|greater|higher|lower|older) than|up to|every|within)\s+\d+(?:[.,]\d+)?(?:\s*${durationUnit}(?!\p{L}))?`, 'giu'),
|
||||||
|
new RegExp(String.raw`(?<![\p{L}\p{N}_])\d+(?:[.,]\d+)?(?:\s*${durationUnit})?\s+or\s+(?:more|less|fewer|greater|higher|lower)\b`, 'giu'),
|
||||||
|
// Recommended target values: "should be set to 1", "configured to 0".
|
||||||
|
new RegExp(String.raw`\b(?:set|configured|changed)\s+to\s+\d+(?:[.,]\d+)?(?![\p{N}.,]*\p{N})`, 'giu'),
|
||||||
|
// The same instruction with its object in between, within one sentence and without other numbers:
|
||||||
|
// "set it to 1", "Set the ms-DS-MachineAccountQuota attribute on the domain NC head to 0".
|
||||||
|
new RegExp(String.raw`\bset\b[^.\n\p{N}]{1,80}?\bto\s+\d+(?![\p{N}.,]*\p{N})`, 'giu'),
|
||||||
|
// Documented values and flags: "the expected new value is 0000…11", "adding the number 1048576",
|
||||||
|
// "assign 1 as the Value", "2 for Audit mode", "(admincount=1)", "zero (0)", "a 20+ length password".
|
||||||
|
/\bvalue\s+(?:is|of)\s+\d+\b/giu,
|
||||||
|
/\bthe\s+(?:number|value|flag)\s+\d+\b/giu,
|
||||||
|
/\bassign\s+\d+\b/giu,
|
||||||
|
/(?<![\p{L}\p{N}_])\d+\s+(?:as\s+the\s+value|for\s+\p{L}+\s+mode)\b/giu,
|
||||||
|
/[\p{L}][\p{L}\p{N}_-]*\s*(?:>=|<=|=)\s*\d+\b/gu,
|
||||||
|
/\b(?:zero|one|two|three)\s*\(\d+\)/giu,
|
||||||
|
/(?<![\p{L}\p{N}_])\d+\+(?=\s)/gu,
|
||||||
|
// Counted alternatives and documented report limits: "through 2 methods", "only 10 will be listed below".
|
||||||
|
/(?<![\p{L}\p{N}_])\d+\s+(?:methods?|ways?|options?)\b/giu,
|
||||||
|
/\bonly\s+\d+\s+will\s+be\s+listed\b/giu
|
||||||
|
];
|
||||||
|
|
||||||
|
function redactEnvironmentQuantities(value: string, keepRecommendationThresholds = false): string {
|
||||||
|
const protectedTokens = new Map<string, string>();
|
||||||
|
const protect = (text: string, pattern: RegExp) => text.replace(pattern, match => {
|
||||||
|
// Grouped thousands (1.015 or 1,015) are counts, not versions; a retained address-range boundary
|
||||||
|
// such as 10.255.255.255 is not a grouped number.
|
||||||
|
if (/^\d{1,3}(?:[.,]\d{3})+$/.test(match) && !publicAddressRangeBoundaries.has(match)) return match;
|
||||||
|
const key = `\uE000${protectedTokenKey(protectedTokens.size)}\uE001`;
|
||||||
|
protectedTokens.set(key, match);
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
let redacted = value
|
||||||
|
.replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/gu, '[date]')
|
||||||
|
.replace(/\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b/gu, '[date]')
|
||||||
|
.replace(/\b\d{1,2}:\d{2}(?::\d{2})?\b/gu, '[time]');
|
||||||
|
// Inline runs first, so a run that starts at a line start ("1. \u2026" newline "\u2026 2. \u2026") stays complete.
|
||||||
|
for (const [start, end] of inlineOrderedListMarkers(redacted).sort((left, right) => right[0] - left[0])) {
|
||||||
|
const key = `\uE000${protectedTokenKey(protectedTokens.size)}\uE001`;
|
||||||
|
protectedTokens.set(key, redacted.slice(start, end));
|
||||||
|
redacted = redacted.slice(0, start) + key + redacted.slice(end);
|
||||||
|
}
|
||||||
|
redacted = protect(redacted, listMarker);
|
||||||
|
if (keepRecommendationThresholds) {
|
||||||
|
for (const pattern of recommendationThresholds) redacted = protect(redacted, pattern);
|
||||||
|
}
|
||||||
|
redacted = redacted
|
||||||
|
.replace(/(?<![\p{L}\p{N}_])\d+(?:[.,]\d+)?\s*(?:(?:second|minute|hour|day|week|month|year)(?:s|\(s\))?|Sekunden?|Minuten?|Stunden?|Tagen?|Tage?|Wochen?|Monaten?|Monate?|Jahren?|Jahre?)(?!\p{L})/giu,
|
||||||
|
'[duration]')
|
||||||
|
.replace(/(?<![\p{L}\p{N}_])\d+(?:ms|s|m|mins?|h|hrs?|d|w|wk|mo|y|yrs?)(?![\p{L}\p{N}_])/gu, '[duration]');
|
||||||
|
for (const pattern of publicNumericTokens) redacted = protect(redacted, pattern);
|
||||||
|
redacted = redacted.replace(/(?<![\p{L}\p{N}_])\d+(?:[.,]\d+)*(?![\p{L}\p{N}_])/gu, '[count]');
|
||||||
|
// Protected tokens can nest (a list marker inside a threshold span), so restore until stable.
|
||||||
|
let restored = redacted;
|
||||||
|
do {
|
||||||
|
redacted = restored;
|
||||||
|
restored = redacted.replace(/\uE000[a-z]+\uE001/gu, key => protectedTokens.get(key) ?? key);
|
||||||
|
} while (restored !== redacted);
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group Policy and registry paths are public setting locations. Their segments
|
||||||
|
* (`Configuration\Policies`, `System\CurrentControlSet`) are not DOMAIN\account pairs. A span runs
|
||||||
|
* from a recognized root to the end of the line or a semicolon.
|
||||||
|
*/
|
||||||
|
const publicPathRoots = new RegExp(String.raw`(?:\b(?:Computer|User) Configuration|\bHK(?:LM|CU|U|CR|CC)\b|\bHKEY_[A-Z_]+|`
|
||||||
|
+ String.raw`\b(?:Software|System)(?=\\(?:Policies|Microsoft|CurrentControlSet|Classes|WOW6432Node)\b)|`
|
||||||
|
+ String.raw`\bApplications and Services Logs|`
|
||||||
|
+ String.raw`\b(?:Windows Settings|Security Settings|Local Policies|Account Policies|Administrative Templates|`
|
||||||
|
+ String.raw`Windows Components|Advanced Audit Policy Configuration))\\[^;\r\n]*`, 'giu');
|
||||||
|
|
||||||
|
function publicPathSpans(value: string): Array<[number, number]> {
|
||||||
|
return [...value.matchAll(publicPathRoots)].map(match => [match.index, match.index + match[0].length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts of vendor and standards documentation. Tenant hosts (`*.onmicrosoft.com`, `*.sharepoint.com`,
|
||||||
|
* `login.microsoftonline.com/<tenant>`) are deliberately absent and stay redacted.
|
||||||
|
*/
|
||||||
|
const publicReferenceHosts = [
|
||||||
|
'microsoft.com', 'cloud.microsoft', 'azure.com', 'office.com', 'github.com', 'maester.dev', 'cisa.gov', 'dhs.gov',
|
||||||
|
'cisecurity.org', 'mitre.org', 'nist.gov', 'pingcastle.com', 'semperis.com', 'owasp.org', 'ietf.org',
|
||||||
|
'rfc-editor.org', 'bsi.bund.de', 'aka.ms', 'windows.com', 'whitehouse.gov', 'citrix.com'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPublicReferenceHost(host: string): boolean {
|
||||||
|
const normalized = host.toLocaleLowerCase('en-US').replace(/\.$/, '');
|
||||||
|
return publicReferenceHosts.some(domain => normalized === domain || normalized.endsWith(`.${domain}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document and program names such as `pfirewall.log`, `gpedit.msc` or `aad.md` are not domains. */
|
||||||
|
const publicFileName = /\.(?:log|exe|dll|msc|msi|cpl|sys|inf|ini|admx|adml|md|rego|ps1|psm1|psd1|json|xml|csv|txt|evtx|etl|cab|reg|pol|cer|pem|pfx|bat|cmd|vbs|html?|aspx?|pdf|docx?|xlsx?|pptx?|png|svg)$/iu;
|
||||||
|
|
||||||
|
/** Dotted PascalCase code identifiers (`RecommendationDetails.ReactView`); DNS names are not written this way. */
|
||||||
|
const pascalCaseIdentifier = /^[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*(?:\.[A-Z][A-Za-z0-9]*)+$/u;
|
||||||
|
|
||||||
|
/** .NET and Windows API namespaces (`System.DateTime`, `Windows.Storage`) and `ASP.NET`. */
|
||||||
|
const codeNamespace = /^(?:(?:System|Microsoft|Windows)(?:\.[A-Z][A-Za-z0-9]*)+|ASP\.NET)$/u;
|
||||||
|
|
||||||
|
/** Sentence-initial words that follow a missing space after a period ("endpoint.The"); never a TLD. */
|
||||||
|
// Two-letter words are excluded because they collide with country TLDs (.in, .it, .to, .as).
|
||||||
|
const sentenceStarter = /^(?:The|This|That|These|Those|Its|For|When|Where|While|See|Note|You|Please|Use|Any|All|Also|After|Before|Once|Otherwise)$/u;
|
||||||
|
|
||||||
|
/** Microsoft Graph permission names (`Directory.ReadWrite.All`, `Policy.ReadWrite.AuthenticationMethod`). */
|
||||||
|
const graphPermission = /^[A-Z][A-Za-z]+\.(?:Read|ReadWrite|ReadBasic|Write|Manage|Send|Create|Delete|Invite|Export|AccessAsUser)[A-Za-z]*(?:\.[A-Z][A-Za-z]+)*$/u;
|
||||||
|
|
||||||
|
function isPublicReferenceUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value.replace(/[.,;:!?]+$/u, ''));
|
||||||
|
if (!/^https?:$/u.test(url.protocol) || url.username || url.password || !isPublicReferenceHost(url.hostname)) return false;
|
||||||
|
// A link that already carries a redaction placeholder or an identifier is tenant-specific.
|
||||||
|
if (/\[|\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/iu.test(value)) return false;
|
||||||
|
// Azure resource paths name customer resources (resource groups, vaults, workspaces).
|
||||||
|
if (/\/(?:subscriptions|resourceGroups|providers|resource)\//iu.test(decodeURIComponent(value))) return false;
|
||||||
|
const rest = decodeURIComponent(`${url.pathname}${url.search}${url.hash}`);
|
||||||
|
if (/@|onmicrosoft|sharepoint/iu.test(rest)) return false;
|
||||||
|
// Any further host-like token in the path must itself be public, a file name or a PascalCase
|
||||||
|
// code identifier such as the Entra blade `RecommendationDetails.ReactView`.
|
||||||
|
return [...rest.matchAll(/(?:[a-z0-9-]+\.)+[a-z]{2,}/giu)]
|
||||||
|
.every(match => isPublicReferenceHost(match[0]) || publicFileName.test(match[0]) || pascalCaseIdentifier.test(match[0])
|
||||||
|
// Microsoft module names in documentation paths (`/module/microsoft.powershell.core/`).
|
||||||
|
|| /^microsoft\.[a-z]/iu.test(match[0]));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipv4Octet = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
|
||||||
|
/** A standalone IPv4 address, never part of a longer dotted sequence such as CIS 18.9.20.1.1. */
|
||||||
|
const ipv4Address = new RegExp(String.raw`(?<![\d.])(?:${ipv4Octet}\.){3}${ipv4Octet}(?!\.?\d)`, 'gu');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boundaries of documented special-purpose ranges (RFC 1918, loopback, link-local) and of the public
|
||||||
|
* address space around RFC 1918 (for example 11.0.0.0 to 172.15.255.255); not host addresses.
|
||||||
|
*/
|
||||||
|
const publicAddressRangeBoundaries = new Set([
|
||||||
|
'0.0.0.0', '255.255.255.255', '10.0.0.0', '10.255.255.255', '172.16.0.0', '172.31.255.255',
|
||||||
|
'192.168.0.0', '192.168.255.255', '127.0.0.0', '127.0.0.1', '127.255.255.255', '169.254.0.0', '169.254.255.255',
|
||||||
|
'9.255.255.255', '11.0.0.0', '126.255.255.255', '128.0.0.0', '128.0.0.1', '172.15.255.255', '172.32.0.0',
|
||||||
|
'192.167.255.255', '192.169.0.0'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isPublicWindowsSid(sid: string): boolean {
|
function isPublicWindowsSid(sid: string): boolean {
|
||||||
return /^(?:S-1-5-32-\d+|S-1-5-(?:18|19|20|11|90-0|82-0)|S-1-1-0)$/iu.test(sid);
|
// Domain SIDs are reduced before this check; the shared list holds only tenant-independent SIDs.
|
||||||
|
return /^(?:S-1-5-32-\d+|S-1-5-(?:18|19|20|11|90-0|82-0)|S-1-1-0)$/iu.test(sid)
|
||||||
|
|| (!/^S-1-5-21-/iu.test(sid) && wellKnownSidName(sid) !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
|
|
@ -225,7 +484,11 @@ function privateLiterals(item: UnifiedResultProjection): string[] {
|
||||||
* Keeps rule guidance useful while removing identifiers that scanner prose can repeat.
|
* Keeps rule guidance useful while removing identifiers that scanner prose can repeat.
|
||||||
* Unknown sources never reach this path: their narrative remains fail-closed.
|
* Unknown sources never reach this path: their narrative remains fail-closed.
|
||||||
*/
|
*/
|
||||||
function redactNarrative(item: UnifiedResultProjection, value?: string): string | undefined {
|
function redactNarrative(
|
||||||
|
item: UnifiedResultProjection,
|
||||||
|
value?: string,
|
||||||
|
field: 'title' | 'description' | 'recommendation' = 'description'
|
||||||
|
): string | undefined {
|
||||||
if (!value?.trim() || !narrativeTools.has(disclosureTool(item).id.replace(/-(?:ad|entra|combined)$/, ''))) return undefined;
|
if (!value?.trim() || !narrativeTools.has(disclosureTool(item).id.replace(/-(?:ad|entra|combined)$/, ''))) return undefined;
|
||||||
let redacted = value.trim()
|
let redacted = value.trim()
|
||||||
.replace(/<(?:br|\/p|\/li|\/div)\s*\/?>/giu, '\n')
|
.replace(/<(?:br|\/p|\/li|\/div)\s*\/?>/giu, '\n')
|
||||||
|
|
@ -237,33 +500,79 @@ function redactNarrative(item: UnifiedResultProjection, value?: string): string
|
||||||
.replace(/"/giu, '"')
|
.replace(/"/giu, '"')
|
||||||
.replace(/'/giu, "'");
|
.replace(/'/giu, "'");
|
||||||
const cisRuleId = disclosureTool(item).id === 'cis-cat' ? safePublicRuleId(item) : undefined;
|
const cisRuleId = disclosureTool(item).id === 'cis-cat' ? safePublicRuleId(item) : undefined;
|
||||||
|
// Public references are held out of every later rule (hosts, quantities) and restored at the end.
|
||||||
|
const references = new Map<string, string>();
|
||||||
|
const keepReference = (value: string) => {
|
||||||
|
const key = `\uE100${protectedTokenKey(references.size)}\uE101`;
|
||||||
|
references.set(key, value);
|
||||||
|
return key;
|
||||||
|
};
|
||||||
redacted = redacted
|
redacted = redacted
|
||||||
|
// Links are judged first and as a whole, before any other placeholder can cut them: a GUID,
|
||||||
|
// address or account inside a link rejects the complete link (`…/subscriptions/<id>/resourceGroups/…`).
|
||||||
|
// Only `]` ends a link, for markdown links whose text is the URL itself.
|
||||||
|
.replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()\]]+/giu, match => {
|
||||||
|
// Sentence punctuation after a link is not part of it.
|
||||||
|
const url = match.replace(/[.,;:!?]+$/u, '');
|
||||||
|
return `${isPublicReferenceUrl(url) ? keepReference(url) : '[url]'}${match.slice(url.length)}`;
|
||||||
|
})
|
||||||
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]')
|
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]')
|
||||||
.replace(/\bS-1-\d+(?:-\d+){2,}\b/giu, match => isPublicWindowsSid(match) ? match : '[sid]')
|
.replace(/\bS-1-\d+(?:-\d+){2,}\b/giu, match => isPublicWindowsSid(match) ? match : '[sid]')
|
||||||
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[id]')
|
// A documented Microsoft GUID is held out like a public reference, so its digit groups are not counts.
|
||||||
.replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]')
|
.replace(guidPattern, match => isPublicGuid(match) ? keepReference(match) : '[id]')
|
||||||
|
.replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]');
|
||||||
|
const pathSpans = publicPathSpans(redacted);
|
||||||
|
redacted = redacted
|
||||||
.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, (match, offset: number, input: string) => {
|
.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, (match, offset: number, input: string) => {
|
||||||
const start = input.lastIndexOf('\n', offset) + 1;
|
// Inside a path, or the direct continuation of a path the source hard-wrapped mid-segment
|
||||||
const end = input.indexOf('\n', offset);
|
// ("…\Terminal" + newline + "Server\UserConfig"): the span ends in a segment character and
|
||||||
const line = input.slice(start, end < 0 ? undefined : end).trim();
|
// the match starts the very next line.
|
||||||
return cisRuleId && /^(?:Computer|User) Configuration\\Administrative Templates\\/iu.test(line)
|
if (pathSpans.some(([start, end]) => offset >= start && (offset < end
|
||||||
&& publicCisPolicyPathPairs.has(match.toLocaleLowerCase('en-US'))
|
|| (/^\r?\n$/u.test(input.slice(end, offset)) && /[\p{L}\p{N}]/u.test(input[end - 1] ?? ''))))) return match;
|
||||||
? match : '[account]';
|
if (input[offset - 1] === '\\' || input[offset + match.length] === '\\') return match;
|
||||||
|
const authority = input.slice(0, offset) + match.slice(0, match.indexOf('\\'));
|
||||||
|
return publicAccountAuthorities.test(authority) ? match : '[account]';
|
||||||
})
|
})
|
||||||
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]')
|
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]')
|
||||||
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]')
|
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]')
|
||||||
.replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()]+/giu, '[url]')
|
.replace(ipv4Address, (match, offset: number, input: string) =>
|
||||||
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, match => cisRuleId === match ? match : '[ip]')
|
(cisRuleId && (cisRuleId === match || cisRuleId.startsWith(`${match}.`)))
|
||||||
|
|| /\bRules?\s+$/iu.test(input.slice(Math.max(0, offset - 8), offset))
|
||||||
|
|| publicAddressRangeBoundaries.has(match) ? match : '[ip]')
|
||||||
.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]')
|
.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]')
|
||||||
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match => cisRuleId && /\.(?:admx|adml)$/iu.test(match) ? match : '[domain]')
|
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, (match, offset: number, input: string) => {
|
||||||
.replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?|Limited)\b/giu, '[organization]');
|
if (input[offset - 1] === '@') return '[domain]';
|
||||||
|
if (isPublicReferenceHost(match)) return keepReference(match);
|
||||||
|
if (publicFileName.test(match) || graphPermission.test(match) || codeNamespace.test(match)
|
||||||
|
|| pascalCaseIdentifier.test(match)) return match;
|
||||||
|
// Property access in code samples (`('Fido2') .keyRestrictions.isEnforced`).
|
||||||
|
if (input[offset - 1] === '.') return match;
|
||||||
|
// A missing space after a sentence ("service.This indicator") is not a host name.
|
||||||
|
if (sentenceStarter.test(match.slice(match.lastIndexOf('.') + 1)) && /^\s/u.test(input.slice(offset + match.length))) return match;
|
||||||
|
// Dotted policy identifiers such as ScubaGear `MS.AAD.2.3` continue with a numeric segment.
|
||||||
|
if (/^\.\d/u.test(input.slice(offset + match.length))) return match;
|
||||||
|
return '[domain]';
|
||||||
|
})
|
||||||
|
// Legal forms are matched in their registered spelling only. `Limited` is omitted: in guidance it is
|
||||||
|
// an ordinary word ("Limited number of Global Admins", "sharing limited to existing guests").
|
||||||
|
.replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?)(?![\p{L}\p{N}_])/gu, '[organization]');
|
||||||
for (const literal of privateLiterals(item)) {
|
for (const literal of privateLiterals(item)) {
|
||||||
redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]');
|
redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]');
|
||||||
}
|
}
|
||||||
|
// CIS-CAT prose is benchmark text; its numbers are public recommendations, not observations.
|
||||||
|
if (disclosureTool(item).id !== 'cis-cat') redacted = redactEnvironmentQuantities(redacted, field === 'recommendation');
|
||||||
|
const literals = privateLiterals(item).map(literal => literal.toLocaleLowerCase('en-US'));
|
||||||
|
redacted = redacted.replace(/\uE100[a-z]+\uE101/gu, key => {
|
||||||
|
const reference = references.get(key) ?? key;
|
||||||
|
// A public reference that still carries a private literal is not public.
|
||||||
|
return literals.some(literal => reference.toLocaleLowerCase('en-US').includes(literal))
|
||||||
|
? (reference.includes('://') ? '[url]' : isPublicGuid(reference) ? '[id]' : '[domain]') : reference;
|
||||||
|
});
|
||||||
redacted = redacted
|
redacted = redacted
|
||||||
.replace(/(?:\[redacted\]\s*){2,}/gu, '[redacted] ')
|
.replace(/(?:\[redacted\]\s*){2,}/gu, '[redacted] ')
|
||||||
.replace(/[ \t]{2,}/g, ' ')
|
.replace(/[ \t]{2,}/g, ' ')
|
||||||
.replace(/\s+([,.;:!?])/g, '$1')
|
// Only sentence punctuation: " .onmicrosoft.com" or " .NET" must keep its leading space.
|
||||||
|
.replace(/\s+([,.;:!?])(?![\p{L}\p{N}])/gu, '$1')
|
||||||
.replace(/\s*\n\s*/g, '\n')
|
.replace(/\s*\n\s*/g, '\n')
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
@ -298,13 +607,13 @@ function controlledTitle(item: UnifiedResultProjection): string {
|
||||||
}
|
}
|
||||||
const tool = disclosureTool(item);
|
const tool = disclosureTool(item);
|
||||||
const ruleId = safePublicRuleId(item);
|
const ruleId = safePublicRuleId(item);
|
||||||
const title = redactNarrative(item, item.title);
|
const title = redactNarrative(item, item.title, 'title');
|
||||||
if (title) return title;
|
if (title) return title;
|
||||||
return ruleId ? `${tool.label} public rule ${ruleId}` : `${tool.label} finding`;
|
return ruleId ? `${tool.label} public rule ${ruleId}` : `${tool.label} finding`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function controlledDescription(item: UnifiedResultProjection): string | undefined {
|
function controlledDescription(item: UnifiedResultProjection): string | undefined {
|
||||||
if (item.sourceKind !== 'policy-evaluation') return redactNarrative(item, item.description);
|
if (item.sourceKind !== 'policy-evaluation') return redactNarrative(item, item.description, 'description');
|
||||||
if (item.policyClassification === 'conflict') {
|
if (item.policyClassification === 'conflict') {
|
||||||
return 'Different retained technical states were detected for the same Group Policy setting.';
|
return 'Different retained technical states were detected for the same Group Policy setting.';
|
||||||
}
|
}
|
||||||
|
|
@ -319,18 +628,128 @@ function controlledDiagnosticCodes(values?: string[]): string[] | undefined {
|
||||||
return safe?.length ? safe : undefined;
|
return safe?.length ? safe : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FieldDisclosure = 'disclose' | 'withhold';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every projection field must be classified here; a new field fails compilation until it is.
|
||||||
|
* `disclose` fields are either safe as-is or rebuilt/redacted below; scope handles are replaced
|
||||||
|
* by removeResultDbScopeIdentifiers and the JSONL export after filtering. `withhold` fields are
|
||||||
|
* internal analysis only: customer identities, provenance, source timestamps and client-owned
|
||||||
|
* review/risk-acceptance state. A per-finding `score` is withheld: scanner indicator scores (Purple
|
||||||
|
* Knight) and computed rule points (PingCastle) scale with the affected objects and calibrate attack effort.
|
||||||
|
*/
|
||||||
|
const RESULT_DB_FIELD_DISCLOSURE: Record<keyof UnifiedResultProjection, FieldDisclosure> = {
|
||||||
|
resultProjectionId: 'disclose', sourceKind: 'disclose', sourceResultId: 'disclose',
|
||||||
|
projectId: 'disclose', assessmentId: 'disclose', collectionRunId: 'disclose', scanRunId: 'disclose',
|
||||||
|
gpoSnapshotId: 'disclose', evaluationRunId: 'disclose',
|
||||||
|
sourceTool: 'disclose', sourceToolScope: 'disclose', sourceToolVersion: 'withhold',
|
||||||
|
sourceProfileId: 'disclose', sourceProfileVersion: 'disclose', mappingRuleVersion: 'disclose',
|
||||||
|
normalizationRuleVersion: 'disclose', presentationVersion: 'disclose', processingCompatibilityKey: 'disclose',
|
||||||
|
processingStatus: 'disclose', reprocessingAvailable: 'disclose',
|
||||||
|
sourceRuleId: 'disclose', canonicalDefinitionId: 'withhold', importArtifactId: 'withhold',
|
||||||
|
sourceViewId: 'withhold', sourceLocator: 'withhold',
|
||||||
|
environmentProperty: 'disclose', environmentSubjectType: 'disclose', environmentSubjectIdentity: 'withhold',
|
||||||
|
environmentValueType: 'disclose', environmentState: 'disclose', environmentOriginalValue: 'withhold',
|
||||||
|
environmentNormalizedValue: 'disclose', environmentSectionKey: 'withhold', environmentAttributes: 'withhold',
|
||||||
|
supplementalDisclosurePolicyVersion: 'disclose', supplementalDisclosureClass: 'disclose',
|
||||||
|
policyScope: 'disclose', policyDeliveryMechanism: 'disclose', policyState: 'disclose',
|
||||||
|
policyIdentityStatus: 'disclose', policyIdentityKey: 'withhold', policyValues: 'withhold',
|
||||||
|
policyDiagnosticCodes: 'disclose', policyBackupInstanceId: 'withhold', policyBackupLinkStatus: 'disclose',
|
||||||
|
policyPreferenceCse: 'disclose', policyPreferenceAction: 'disclose', policyPreferenceTargeting: 'disclose',
|
||||||
|
policyGpoId: 'withhold', policyNativeBackupId: 'withhold', policySourceSequence: 'withhold',
|
||||||
|
policyBaselineId: 'withhold', policyBaselineLabel: 'disclose', policyBaselineValue: 'disclose',
|
||||||
|
policyBaselineVariantAssessment: 'disclose', policyActualValue: 'withhold', policyComparisonRelation: 'disclose',
|
||||||
|
policyCustomerState: 'disclose', policyClassification: 'disclose', policySourceCount: 'withhold',
|
||||||
|
title: 'disclose', description: 'disclose', recommendation: 'disclose', affectedAsset: 'withhold', score: 'withhold',
|
||||||
|
severityOriginal: 'disclose', severityNormalized: 'disclose', outcomeOriginal: 'disclose',
|
||||||
|
outcomeNormalized: 'disclose', technicalExecutionState: 'disclose',
|
||||||
|
resultType: 'disclose', isIoe: 'disclose', isDeviation: 'disclose', exposureState: 'disclose',
|
||||||
|
effectiveState: 'disclose', lifecycleState: 'disclose', mappingConfidence: 'disclose',
|
||||||
|
applicabilityState: 'disclose', completenessState: 'disclose', scopeState: 'disclose',
|
||||||
|
effectivenessVerification: 'disclose', scopeConfidence: 'disclose',
|
||||||
|
reviewStatus: 'withhold', reviewDisposition: 'withhold', reviewPriority: 'withhold',
|
||||||
|
reviewOwner: 'withhold', reviewDueDate: 'withhold',
|
||||||
|
sourceObservedAt: 'withhold', importedAt: 'disclose', evaluatedAt: 'withhold', createdAt: 'disclose',
|
||||||
|
categories: 'disclose', frameworkReferences: 'disclose', disclosureTrace: 'disclose'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drops withheld and unclassified fields, so fields added at runtime cannot pass through. */
|
||||||
|
function onlyDisclosedFields(item: UnifiedResultProjection): UnifiedResultProjection {
|
||||||
|
return Object.fromEntries(Object.entries(item).filter(([key]) =>
|
||||||
|
RESULT_DB_FIELD_DISCLOSURE[key as keyof UnifiedResultProjection] === 'disclose')) as unknown as UnifiedResultProjection;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail-closed public projection used by Result DB and every downstream LLM/export path.
|
* Fail-closed public projection used by Result DB and every downstream LLM/export path.
|
||||||
* Internal analysis keeps the canonical source records and provenance unchanged.
|
* Internal analysis keeps the canonical source records and provenance unchanged.
|
||||||
*/
|
*/
|
||||||
export function discloseResultDbProjection(item: UnifiedResultProjection): UnifiedResultProjection {
|
export function discloseResultDbProjection(item: UnifiedResultProjection, identityGuard?: ResultDbIdentityGuard): UnifiedResultProjection {
|
||||||
|
return applyResultDbIdentityGuard(onlyDisclosedFields(disclosedValues(item)), identityGuard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracedFields = [
|
||||||
|
'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue',
|
||||||
|
'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories'
|
||||||
|
] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>;
|
||||||
|
|
||||||
|
const untracedWithheldFields = new Set<string>([
|
||||||
|
'reviewStatus', 'reviewDisposition', 'reviewPriority', 'reviewOwner', 'reviewDueDate'
|
||||||
|
] satisfies Array<keyof UnifiedResultProjection>);
|
||||||
|
|
||||||
|
function hasValue(value: unknown): boolean {
|
||||||
|
return value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The materialized Result DB row: the disclosed projection plus its content-free redaction trace.
|
||||||
|
* The trace is derived from the placeholders each step added per field, so it describes exactly the
|
||||||
|
* stored row and never repeats an original value.
|
||||||
|
*/
|
||||||
|
export function discloseResultDbProjectionWithTrace(
|
||||||
|
item: UnifiedResultProjection,
|
||||||
|
identityGuard?: ResultDbIdentityGuard
|
||||||
|
): UnifiedResultProjection {
|
||||||
|
const disclosed = onlyDisclosedFields(disclosedValues(item));
|
||||||
|
const guarded = applyResultDbIdentityGuard(disclosed, identityGuard);
|
||||||
|
const redactions: ResultDbDisclosureTrace['redactions'] = [];
|
||||||
|
for (const field of tracedFields) {
|
||||||
|
for (const entry of addedPlaceholders(item[field], disclosed[field])) redactions.push({ field, ...entry });
|
||||||
|
for (const entry of addedPlaceholders(disclosed[field], guarded[field], rule => rule === 'private-value' ? 'identity-guard' : rule)) {
|
||||||
|
redactions.push({ field, ...entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.policyBaselineLabel && guarded.policyBaselineLabel && item.policyBaselineLabel !== guarded.policyBaselineLabel) {
|
||||||
|
redactions.push({ field: 'policyBaselineLabel', rule: 'baseline-family', count: 1 });
|
||||||
|
}
|
||||||
|
const collapsed = (item.policyBaselineVariantAssessment?.variants.length ?? 0) - (guarded.policyBaselineVariantAssessment?.variants.length ?? 0);
|
||||||
|
if (collapsed > 0) redactions.push({ field: 'policyBaselineVariantAssessment', rule: 'baseline-expectations-collapsed', count: collapsed });
|
||||||
|
// Client-owned review state is withheld without a trace entry: even its presence would reveal
|
||||||
|
// which findings the client has triaged or prioritized.
|
||||||
|
const withheldFields = Object.keys(item)
|
||||||
|
.filter(key => !untracedWithheldFields.has(key))
|
||||||
|
.filter(key => hasValue(item[key as keyof UnifiedResultProjection]) && !hasValue(guarded[key as keyof UnifiedResultProjection]))
|
||||||
|
.sort();
|
||||||
|
const guardWithheldFields = tracedFields.filter(field => JSON.stringify(guarded[field] ?? '').includes('[withheld by identity guard]'));
|
||||||
|
return {
|
||||||
|
...guarded,
|
||||||
|
disclosureTrace: {
|
||||||
|
version: 1,
|
||||||
|
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
|
||||||
|
identityGuardVersion: RESULT_DB_IDENTITY_GUARD_VERSION,
|
||||||
|
redactions,
|
||||||
|
withheldFields,
|
||||||
|
guardWithheldFields
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disclosedValues(item: UnifiedResultProjection): UnifiedResultProjection {
|
||||||
const tool = disclosureTool(item);
|
const tool = disclosureTool(item);
|
||||||
const policyEvaluation = item.sourceKind === 'policy-evaluation';
|
const policyEvaluation = item.sourceKind === 'policy-evaluation';
|
||||||
const policyRedactor = policyEvaluation ? new PolicyValueRedactor() : undefined;
|
const policyRedactor = policyEvaluation ? new PolicyValueRedactor() : undefined;
|
||||||
const rawPublicRuleId = safePublicRuleId(item);
|
const rawPublicRuleId = safePublicRuleId(item);
|
||||||
const publicRuleId = policyRedactor && rawPublicRuleId ? policyRedactor.text(rawPublicRuleId, false) : rawPublicRuleId;
|
const publicRuleId = policyRedactor && rawPublicRuleId ? policyRedactor.text(rawPublicRuleId, false) : rawPublicRuleId;
|
||||||
const policyDescription = policyRedactor ? safePolicyNarrative(item, policyRedactor, item.description) : undefined;
|
const policyDescription = policyRedactor ? safePolicyNarrative(item, policyRedactor, item.description) : undefined;
|
||||||
const publicActualValue = policyRedactor ? policyRedactor.json(item.policyActualValue) : undefined;
|
|
||||||
const publicBaselineValue = policyRedactor ? policyRedactor.json(item.policyBaselineValue) : undefined;
|
const publicBaselineValue = policyRedactor ? policyRedactor.json(item.policyBaselineValue) : undefined;
|
||||||
const publicBaselineVariants = policyRedactor
|
const publicBaselineVariants = policyRedactor
|
||||||
? safeBaselineVariantAssessment(item.policyBaselineVariantAssessment, policyRedactor)
|
? safeBaselineVariantAssessment(item.policyBaselineVariantAssessment, policyRedactor)
|
||||||
|
|
@ -357,18 +776,21 @@ export function discloseResultDbProjection(item: UnifiedResultProjection): Unifi
|
||||||
policySourceSequence: undefined,
|
policySourceSequence: undefined,
|
||||||
policyIdentityKey: undefined,
|
policyIdentityKey: undefined,
|
||||||
policyBaselineId: undefined,
|
policyBaselineId: undefined,
|
||||||
policyBaselineLabel: policyRedactor ? safeBaselineLabel(item, policyRedactor) : undefined,
|
policyBaselineLabel: policyRedactor ? safeBaselineLabel(item) : undefined,
|
||||||
policyBaselineValue: publicBaselineValue,
|
policyBaselineValue: publicBaselineValue,
|
||||||
policyBaselineVariantAssessment: publicBaselineVariants,
|
policyBaselineVariantAssessment: publicBaselineVariants,
|
||||||
policyActualValue: publicActualValue,
|
policyActualValue: undefined,
|
||||||
|
policySourceCount: undefined,
|
||||||
policyDiagnosticCodes: controlledDiagnosticCodes(item.policyDiagnosticCodes),
|
policyDiagnosticCodes: controlledDiagnosticCodes(item.policyDiagnosticCodes),
|
||||||
title: policyRedactor ? policyRedactor.text(controlledTitle(item)) : controlledTitle(item),
|
// A GPO title is the technical setting identity (ADMX name, registry key or section/key), like the
|
||||||
|
// rule ID; `Software\Policies` is a registry path, not a DOMAIN\account pair.
|
||||||
|
title: policyRedactor ? policyRedactor.text(controlledTitle(item), false) : controlledTitle(item),
|
||||||
description: policyEvaluation
|
description: policyEvaluation
|
||||||
? [policyDescription, controlledDescription(item)].filter(Boolean).join(' ')
|
? [policyDescription, controlledDescription(item)].filter(Boolean).join(' ')
|
||||||
: controlledDescription(item),
|
: controlledDescription(item),
|
||||||
recommendation: policyRedactor
|
recommendation: policyRedactor
|
||||||
? safePolicyNarrative(item, policyRedactor, item.recommendation)
|
? safePolicyNarrative(item, policyRedactor, item.recommendation)
|
||||||
: redactNarrative(item, item.recommendation),
|
: redactNarrative(item, item.recommendation, 'recommendation'),
|
||||||
affectedAsset: undefined,
|
affectedAsset: undefined,
|
||||||
severityOriginal: item.severityNormalized,
|
severityOriginal: item.severityNormalized,
|
||||||
outcomeOriginal: item.outcomeNormalized,
|
outcomeOriginal: item.outcomeNormalized,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import type { AssessmentProject, JsonValue } from './canonical-model';
|
||||||
|
import type { EnvironmentObservation } from './environment-context';
|
||||||
|
import type { UnifiedResultProjection } from './unified-result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-specific identity guard for the Result DB (user decision 2026-09-28).
|
||||||
|
*
|
||||||
|
* Pattern-based redaction cannot recognize a host name such as `DC01` or a person's name in free
|
||||||
|
* text. The guard therefore collects the identities a client's own data already names (client name,
|
||||||
|
* domain and tenant names, host and account names, affected assets, review owners) and removes them
|
||||||
|
* from every disclosed Result DB row of that client, independent of their textual form. It runs inside
|
||||||
|
* the disclosure projection, so API queries, single reads, the UI and the JSONL export are all covered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const RESULT_DB_IDENTITY_GUARD_VERSION = '1.3.0';
|
||||||
|
|
||||||
|
export interface ResultDbIdentitySources {
|
||||||
|
projects: AssessmentProject[];
|
||||||
|
environmentObservations: EnvironmentObservation[];
|
||||||
|
affectedAssets: Array<string | undefined>;
|
||||||
|
reviewOwners: Array<string | undefined>;
|
||||||
|
/** Account-like values from internal analysis (`DOMAIN\user`, UPNs, GPO principals). */
|
||||||
|
principals?: Array<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic words that must never become client identities, even when a client uses them as a label. */
|
||||||
|
const genericWords = new Set([
|
||||||
|
'local', 'corp', 'domain', 'domains', 'forest', 'tenant', 'admin', 'admins', 'administrator', 'administrators',
|
||||||
|
'user', 'users', 'guest', 'guests', 'default', 'server', 'servers', 'computer', 'computers', 'windows',
|
||||||
|
'microsoft', 'system', 'service', 'services', 'network', 'global', 'internal', 'intern', 'office', 'group',
|
||||||
|
'groups', 'account', 'accounts', 'builtin', 'authority', 'everyone', 'unknown', 'none', 'true', 'false',
|
||||||
|
// Documented built-in accounts and policies named in public scanner guidance.
|
||||||
|
'krbtgt', 'defaultaccount', 'wdagutilityaccount', 'localhost', 'default domain policy',
|
||||||
|
'default domain controllers policy', 'domain controllers', 'domain admins', 'enterprise admins', 'schema admins',
|
||||||
|
// Well-known Active Directory groups, named in public guidance for every client.
|
||||||
|
'server operators', 'account operators', 'backup operators', 'print operators', 'dns admins', 'dnsadmins',
|
||||||
|
'dnsupdateproxy', 'cert publishers', 'protected users', 'remote desktop users', 'group policy creator owners',
|
||||||
|
'key admins', 'enterprise key admins', 'read-only domain controllers', 'cloneable domain controllers',
|
||||||
|
'domain users', 'domain computers', 'domain guests', 'pre-windows 2000 compatible access',
|
||||||
|
// Scanner and product names.
|
||||||
|
'pingcastle', 'maester', 'purple knight', 'purpleknight', 'cis-cat'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const publicAuthorities = /^(?:BUILTIN|NT AUTHORITY|NT SERVICE|NT VIRTUAL MACHINE|RESTRICTED SERVICES|Window Manager|Font Driver Host)$/iu;
|
||||||
|
|
||||||
|
/** Environment properties whose values name the client (domain, forest, tenant, host). */
|
||||||
|
// Only the client's own domain, forest, host and tenant properties; other `*.name` properties (for
|
||||||
|
// example certificate template names) hold public object names.
|
||||||
|
const identityProperty = /^(?:domain|forest|host|computer|dc|entra|tenant)\.(?:fqdn|name|netbios|netbios_name|hostname|dns_name|tenant_name|tenant_id|domain)$/iu;
|
||||||
|
|
||||||
|
function normalized(value: string): string {
|
||||||
|
return value.toLocaleLowerCase('en-US').replace(/[^\p{L}\p{N}]+/gu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function usable(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length >= 3 && trimmed.length <= 200 && !/^\d+$/u.test(trimmed)
|
||||||
|
&& !genericWords.has(trimmed.toLocaleLowerCase('en-US')) && !/^S-1-\d/iu.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(value: string): string[] {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const parts = [trimmed];
|
||||||
|
const account = /^([^\\/@\s]+)\\(.+)$/u.exec(trimmed);
|
||||||
|
if (account) {
|
||||||
|
if (!publicAuthorities.test(account[1])) parts.push(account[1], account[2]);
|
||||||
|
else return [];
|
||||||
|
}
|
||||||
|
const upn = /^([^@\s]+)@([^@\s]+\.[^@\s]+)$/u.exec(trimmed);
|
||||||
|
if (upn) parts.push(upn[1], upn[2]);
|
||||||
|
for (const part of [...parts]) {
|
||||||
|
// The first label of a host or domain name (`sevenkingdoms` of `sevenkingdoms.local`) names the client.
|
||||||
|
const labels = part.split('.');
|
||||||
|
if (labels.length > 1 && /^[\p{L}\p{N}-]+$/u.test(labels[0]) && labels[0].length >= 5) parts.push(labels[0]);
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host, account and domain names: a digit (`DC01`), a dot, backslash or `@` (`corp.local`,
|
||||||
|
* `CORP\\jon`, `jon@corp.local`), or an all-lowercase sAMAccountName (`tywin`). Capitalized words and
|
||||||
|
* phrases without such marks are categories or public object names and are not taken from asset columns.
|
||||||
|
*/
|
||||||
|
function identityShaped(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (/\s/u.test(trimmed) && !/[\\@.]/u.test(trimmed)) return false;
|
||||||
|
return /\d|[.\\@]/u.test(trimmed) || /^[a-z][a-z0-9_-]{3,}$/u.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strings(value: JsonValue | undefined): string[] {
|
||||||
|
if (typeof value === 'string') return [value];
|
||||||
|
if (Array.isArray(value)) return value.flatMap(strings);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest first, so `sevenkingdoms.local` is replaced before `sevenkingdoms`. */
|
||||||
|
export function collectResultDbIdentityLiterals(sources: ResultDbIdentitySources): string[] {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const project of sources.projects) {
|
||||||
|
candidates.push(project.name);
|
||||||
|
for (const value of Object.values(project.attributes ?? {})) candidates.push(...strings(value));
|
||||||
|
}
|
||||||
|
for (const observation of sources.environmentObservations) {
|
||||||
|
// Only identity properties name the client; subject and section labels are report vocabulary.
|
||||||
|
if (!identityProperty.test(observation.property)) continue;
|
||||||
|
candidates.push(...strings(observation.originalValue), ...strings(observation.normalizedValue));
|
||||||
|
if (observation.subject.identity) candidates.push(observation.subject.identity);
|
||||||
|
}
|
||||||
|
// Scanner asset columns also hold categories and public object names ("Registry", "SubCA");
|
||||||
|
// only values shaped like an identity are taken from them.
|
||||||
|
for (const value of sources.affectedAssets) if (value && identityShaped(value)) candidates.push(value);
|
||||||
|
for (const value of [...sources.reviewOwners, ...(sources.principals ?? [])]) if (value) candidates.push(value);
|
||||||
|
const unique = new Map<string, string>();
|
||||||
|
for (const candidate of candidates.flatMap(expand)) {
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!usable(trimmed)) continue;
|
||||||
|
const key = trimmed.toLocaleLowerCase('en-US');
|
||||||
|
if (!unique.has(key)) unique.set(key, trimmed);
|
||||||
|
}
|
||||||
|
return [...unique.values()].sort((left, right) => right.length - left.length || left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResultDbIdentityGuard {
|
||||||
|
private readonly patterns: RegExp[];
|
||||||
|
private readonly variantPatterns: RegExp[];
|
||||||
|
|
||||||
|
constructor(literals: readonly string[]) {
|
||||||
|
this.patterns = literals.map(literal =>
|
||||||
|
new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(literal)}(?![\\p{L}\\p{N}])`, 'giu'));
|
||||||
|
// The fail-closed check tolerates separators between the characters (`seven-kingdoms`, `DC 01`)
|
||||||
|
// but, like the exact pass, only matches whole tokens, never a fragment of another word.
|
||||||
|
this.variantPatterns = [...new Set(literals.map(normalized).filter(value => value.length >= 5))]
|
||||||
|
.map(value => new RegExp(`(?<![\\p{L}\\p{N}])${[...value].map(escapeRegExp).join('[\\s._\\\\/-]*')}(?![\\p{L}\\p{N}])`, 'iu'));
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.patterns.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces every known identity; a field that still contains one in another form is withheld. */
|
||||||
|
text(value: string): string {
|
||||||
|
let result = value;
|
||||||
|
for (const pattern of this.patterns) result = result.replace(pattern, '[redacted]');
|
||||||
|
return this.variantPatterns.some(pattern => pattern.test(result)) ? '[withheld by identity guard]' : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
json<T>(value: T): T {
|
||||||
|
if (typeof value === 'string') return this.text(value) as T;
|
||||||
|
if (Array.isArray(value)) return value.map(item => this.json(item)) as T;
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, this.json(child)])) as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account forms only: `DOMAIN\name` with exactly one backslash and no registry root, or a UPN.
|
||||||
|
* GPO values also hold registry keys and paths (`Software\Policies\…`), which name no client.
|
||||||
|
*/
|
||||||
|
function accountLike(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const account = /^([^\\\s]+)\\([^\\]+)$/u.exec(trimmed);
|
||||||
|
if (account) return !/^(?:HK[A-Z_]*|HKEY_\w+|Software|System|Machine|User|Policies|Registry|Classes)$/iu.test(account[1]);
|
||||||
|
return /^[^@\s]+@[^@\s]+\.[a-z]{2,}$/iu.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLikeStrings(value: unknown): string[] {
|
||||||
|
if (typeof value === 'string') return accountLike(value) ? [value] : [];
|
||||||
|
if (Array.isArray(value)) return value.flatMap(accountLikeStrings);
|
||||||
|
if (value && typeof value === 'object') return Object.values(value).flatMap(accountLikeStrings);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one guard per client from its complete internal data. `internalResults` must be the
|
||||||
|
* unfiltered internal-analysis projection of those clients, never a filtered query page.
|
||||||
|
*/
|
||||||
|
export function buildResultDbIdentityGuards(input: {
|
||||||
|
projects: AssessmentProject[];
|
||||||
|
environmentObservations: Array<{ projectId: string; observation: EnvironmentObservation }>;
|
||||||
|
internalResults: UnifiedResultProjection[];
|
||||||
|
}): Map<string, ResultDbIdentityGuard> {
|
||||||
|
const guards = new Map<string, ResultDbIdentityGuard>();
|
||||||
|
for (const project of input.projects) {
|
||||||
|
const results = input.internalResults.filter(item => item.projectId === project.id);
|
||||||
|
guards.set(project.id, new ResultDbIdentityGuard(collectResultDbIdentityLiterals({
|
||||||
|
projects: [project],
|
||||||
|
environmentObservations: input.environmentObservations
|
||||||
|
.filter(entry => entry.projectId === project.id).map(entry => entry.observation),
|
||||||
|
affectedAssets: results.map(item => item.affectedAsset),
|
||||||
|
reviewOwners: results.map(item => item.reviewOwner),
|
||||||
|
principals: results.flatMap(item => [
|
||||||
|
...accountLikeStrings(item.policyActualValue), ...accountLikeStrings(item.policyValues),
|
||||||
|
...accountLikeStrings(item.environmentSubjectIdentity)
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
return guards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content fields of a disclosed row; identifiers, enums and version fields are never rewritten. */
|
||||||
|
const guardedFields = [
|
||||||
|
'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue',
|
||||||
|
'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories'
|
||||||
|
] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>;
|
||||||
|
|
||||||
|
export function applyResultDbIdentityGuard(item: UnifiedResultProjection, guard?: ResultDbIdentityGuard): UnifiedResultProjection {
|
||||||
|
if (!guard?.size) return item;
|
||||||
|
const guarded: UnifiedResultProjection = { ...item };
|
||||||
|
for (const field of guardedFields) {
|
||||||
|
if (guarded[field] !== undefined) (guarded as unknown as Record<string, unknown>)[field] = guard.json(guarded[field]);
|
||||||
|
}
|
||||||
|
return guarded;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { discloseResultDbProjectionWithTrace, RESULT_DB_DISCLOSURE_POLICY_VERSION } from './result-db-disclosure';
|
||||||
|
import { RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard';
|
||||||
|
import type { UnifiedResultProjection } from './unified-result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialized Result DB (user decision 2026-09-28): rows are disclosed, checked and stored when the
|
||||||
|
* source data changes. UI, API and JSONL export read only these stored rows, so what is shown is
|
||||||
|
* exactly what is delivered; each row carries its content-free redaction trace.
|
||||||
|
*/
|
||||||
|
export interface ResultDbMaterializationStatus {
|
||||||
|
/** `current`: stored rows match the current repository revision and policy versions. */
|
||||||
|
state: 'current' | 'outdated' | 'not-built';
|
||||||
|
sourceRevision?: number;
|
||||||
|
repositoryRevision: number;
|
||||||
|
disclosurePolicyVersion?: string;
|
||||||
|
identityGuardVersion?: string;
|
||||||
|
materializedAt?: string;
|
||||||
|
rowCount: number;
|
||||||
|
redactionCount: number;
|
||||||
|
/** Rows in which the identity guard withheld at least one complete field. */
|
||||||
|
guardWithheldRowCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterializedResultDb {
|
||||||
|
rows: UnifiedResultProjection[];
|
||||||
|
status: ResultDbMaterializationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeResultDb(
|
||||||
|
sourceResults: UnifiedResultProjection[],
|
||||||
|
guards: Map<string, ResultDbIdentityGuard>,
|
||||||
|
sourceRevision: number,
|
||||||
|
materializedAt: string
|
||||||
|
): MaterializedResultDb {
|
||||||
|
const rows = sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId)));
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
status: {
|
||||||
|
state: 'current',
|
||||||
|
sourceRevision,
|
||||||
|
repositoryRevision: sourceRevision,
|
||||||
|
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
|
||||||
|
identityGuardVersion: RESULT_DB_IDENTITY_GUARD_VERSION,
|
||||||
|
materializedAt,
|
||||||
|
rowCount: rows.length,
|
||||||
|
redactionCount: rows.reduce((sum, row) => sum
|
||||||
|
+ (row.disclosureTrace?.redactions.reduce((total, entry) => total + entry.count, 0) ?? 0), 0),
|
||||||
|
guardWithheldRowCount: rows.filter(row => row.disclosureTrace?.guardWithheldFields.length).length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored state is current only for the same repository revision and the same policy versions. */
|
||||||
|
export function isResultDbCurrent(status: Pick<ResultDbMaterializationStatus, 'sourceRevision' | 'disclosurePolicyVersion' | 'identityGuardVersion'> | undefined, repositoryRevision: number): boolean {
|
||||||
|
return status?.sourceRevision === repositoryRevision
|
||||||
|
&& status.disclosurePolicyVersion === RESULT_DB_DISCLOSURE_POLICY_VERSION
|
||||||
|
&& status.identityGuardVersion === RESULT_DB_IDENTITY_GUARD_VERSION;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { JsonValue } from './canonical-model';
|
import type { JsonValue } from './canonical-model';
|
||||||
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
|
import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context';
|
||||||
|
|
||||||
export const RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION = '1.0.0';
|
export const RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION = '2.0.0';
|
||||||
|
|
||||||
export type ResultDbSupplementalDisclosureClass =
|
export type ResultDbSupplementalDisclosureClass =
|
||||||
| 'aggregate'
|
| 'aggregate'
|
||||||
|
|
@ -101,6 +101,14 @@ const pingCastleRules: DisclosureRule[] = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact counts, scores, functional levels and versions fingerprint the assessed environment
|
||||||
|
* (for example a single domain controller) and calibrate attack effort. Since policy 2.0.0 only
|
||||||
|
* binary configuration states cross the Result DB boundary; the other rules stay classified so
|
||||||
|
* their values remain internal instead of silently becoming unknown fields.
|
||||||
|
*/
|
||||||
|
const DISCLOSED_CLASSES = new Set<ResultDbSupplementalDisclosureClass>(['configuration-state']);
|
||||||
|
|
||||||
const disclosureRules = new Map(
|
const disclosureRules = new Map(
|
||||||
pingCastleRules.map(rule => [`${rule.toolId}\u0000${rule.property}`, rule])
|
pingCastleRules.map(rule => [`${rule.toolId}\u0000${rule.property}`, rule])
|
||||||
);
|
);
|
||||||
|
|
@ -116,7 +124,7 @@ export function discloseSupplementalObservation(
|
||||||
): ResultDbSupplementalDisclosure | undefined {
|
): ResultDbSupplementalDisclosure | undefined {
|
||||||
const toolId = observation.tool.id.trim().toLocaleLowerCase('en-US');
|
const toolId = observation.tool.id.trim().toLocaleLowerCase('en-US');
|
||||||
const rule = disclosureRules.get(`${toolId}\u0000${observation.property}`);
|
const rule = disclosureRules.get(`${toolId}\u0000${observation.property}`);
|
||||||
if (!rule) return undefined;
|
if (!rule || !DISCLOSED_CLASSES.has(rule.disclosureClass)) return undefined;
|
||||||
const disclosedValue = rule.disclose(observation.normalizedValue ?? observation.originalValue);
|
const disclosedValue = rule.disclose(observation.normalizedValue ?? observation.originalValue);
|
||||||
if (disclosedValue === undefined) return undefined;
|
if (disclosedValue === undefined) return undefined;
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Content-free redaction trace of one disclosed Result DB row (user decision 2026-09-28).
|
||||||
|
* It records which rule changed which field how often, and which fields were withheld; it never
|
||||||
|
* contains an original value, so it is disclosed, shown and exported together with the row.
|
||||||
|
*/
|
||||||
|
export interface ResultDbDisclosureTrace {
|
||||||
|
version: 1;
|
||||||
|
disclosurePolicyVersion: string;
|
||||||
|
identityGuardVersion: string;
|
||||||
|
/** Replacements per field and rule, for example `description` / `count` / 3. */
|
||||||
|
redactions: Array<{ field: string; rule: string; count: number }>;
|
||||||
|
/** Internal fields that held a value but are never part of the Result DB (for example `policyActualValue`). */
|
||||||
|
withheldFields: string[];
|
||||||
|
/** Fields the identity guard withheld completely because a client identity survived in another form. */
|
||||||
|
guardWithheldFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderRules: Array<[RegExp, string]> = [
|
||||||
|
[/\[count\]/g, 'count'], [/\[duration\]/g, 'duration'], [/\[date\]/g, 'date'], [/\[time\]/g, 'time'],
|
||||||
|
[/\[email\]/g, 'email'], [/\[sid\]/g, 'sid'], [/\[id\]|\[ID\]/g, 'identifier'],
|
||||||
|
[/\[directory-object\]/g, 'directory-object'], [/\[account\]/g, 'account'], [/\[network-path\]/g, 'network-path'],
|
||||||
|
[/\[path\]|\[PATH\]/g, 'path'], [/\[url\]/g, 'url'], [/\[ip\]/g, 'ip-address'], [/\[mac\]/g, 'mac-address'],
|
||||||
|
[/\[domain\]|\[DOMAIN\]/g, 'domain'], [/\[organization\]/g, 'organization'], [/\[redacted\]/g, 'private-value'],
|
||||||
|
[/\[(?:DOMAIN|SYSTEM)_PRINCIPAL_\d+\]/g, 'principal'], [/\[withheld by identity guard\]/g, 'identity-guard-withheld']
|
||||||
|
];
|
||||||
|
|
||||||
|
function serialized(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeholderCounts(value: unknown): Map<string, number> {
|
||||||
|
const text = serialized(value);
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const [pattern, rule] of placeholderRules) {
|
||||||
|
const count = text.match(pattern)?.length ?? 0;
|
||||||
|
if (count) counts.set(rule, count);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the placeholders a step added to one field. `before` is the field before the step, so
|
||||||
|
* placeholders that already existed (for example in the source text) are not attributed to it.
|
||||||
|
*/
|
||||||
|
export function addedPlaceholders(before: unknown, after: unknown, renameRule?: (rule: string) => string): Array<{ rule: string; count: number }> {
|
||||||
|
const previous = placeholderCounts(before);
|
||||||
|
return [...placeholderCounts(after)].flatMap(([rule, count]) => {
|
||||||
|
const added = count - (previous.get(rule) ?? 0);
|
||||||
|
return added > 0 ? [{ rule: renameRule ? renameRule(rule) : rule, count: added }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -227,6 +227,12 @@ export interface ToolProfileDefinition {
|
||||||
firstRead?: {
|
firstRead?: {
|
||||||
/** Concrete result statement of the scanner for this environment. */
|
/** Concrete result statement of the scanner for this environment. */
|
||||||
resultHeaders?: RegExp[];
|
resultHeaders?: RegExp[];
|
||||||
|
/**
|
||||||
|
* Native result text whose first prose paragraph states the result (user decision 2026-09-28,
|
||||||
|
* Maester `ResultDetail`). The paragraph is shown verbatim; headings, tables and the rest of the
|
||||||
|
* field stay in Results.
|
||||||
|
*/
|
||||||
|
resultLeadHeaders?: RegExp[];
|
||||||
/** Explicit count of affected results. */
|
/** Explicit count of affected results. */
|
||||||
affectedHeaders?: RegExp[];
|
affectedHeaders?: RegExp[];
|
||||||
/** Count linked result-table rows when no explicit count exists. */
|
/** Count linked result-table rows when no explicit count exists. */
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
discloseSupplementalObservation,
|
discloseSupplementalObservation,
|
||||||
type ResultDbSupplementalDisclosureClass
|
type ResultDbSupplementalDisclosureClass
|
||||||
} from './result-db-supplemental-disclosure';
|
} from './result-db-supplemental-disclosure';
|
||||||
|
import type { ResultDbDisclosureTrace } from './result-db-trace';
|
||||||
|
|
||||||
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation';
|
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation';
|
||||||
export type UnifiedResultAudience = 'result-db' | 'internal-analysis';
|
export type UnifiedResultAudience = 'result-db' | 'internal-analysis';
|
||||||
|
|
@ -174,7 +175,8 @@ export interface UnifiedResultProjection {
|
||||||
scopeState?: 'known' | 'unknown';
|
scopeState?: 'known' | 'unknown';
|
||||||
effectivenessVerification?: 'verified' | 'not-verified';
|
effectivenessVerification?: 'verified' | 'not-verified';
|
||||||
scopeConfidence?: number;
|
scopeConfidence?: number;
|
||||||
reviewStatus: UnifiedReviewStatus;
|
/** Client-owned review state; internal analysis only, withheld by the Result DB disclosure policy. */
|
||||||
|
reviewStatus?: UnifiedReviewStatus;
|
||||||
reviewDisposition?: UnifiedReviewDisposition;
|
reviewDisposition?: UnifiedReviewDisposition;
|
||||||
reviewPriority?: ReviewPriority;
|
reviewPriority?: ReviewPriority;
|
||||||
reviewOwner?: string;
|
reviewOwner?: string;
|
||||||
|
|
@ -185,6 +187,8 @@ export interface UnifiedResultProjection {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
categories: UnifiedResultCategory[];
|
categories: UnifiedResultCategory[];
|
||||||
frameworkReferences: UnifiedResultFrameworkReference[];
|
frameworkReferences: UnifiedResultFrameworkReference[];
|
||||||
|
/** Result DB only: content-free redaction trace stored with the materialized row. */
|
||||||
|
disclosureTrace?: ResultDbDisclosureTrace;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedResultQuery {
|
export interface UnifiedResultQuery {
|
||||||
|
|
@ -1154,8 +1158,6 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer
|
||||||
facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'),
|
facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'),
|
||||||
facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'),
|
facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'),
|
||||||
facet('processingStatus', 'Processing Status', item => item.processingStatus, 'processingStatus'),
|
facet('processingStatus', 'Processing Status', item => item.processingStatus, 'processingStatus'),
|
||||||
facet('reviewStatus', 'Review Status', item => findingAxis(item) ? item.reviewStatus : undefined),
|
|
||||||
facet('reviewDisposition', 'Review Disposition', item => item.reviewDisposition, 'reviewDisposition'),
|
|
||||||
facet('lifecycleState', 'Lifecycle', item => item.lifecycleState, 'lifecycleState'),
|
facet('lifecycleState', 'Lifecycle', item => item.lifecycleState, 'lifecycleState'),
|
||||||
facet('effectiveState', 'Effectiveness', item => item.effectiveState),
|
facet('effectiveState', 'Effectiveness', item => item.effectiveState),
|
||||||
categoryFacet()
|
categoryFacet()
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,21 @@ import type {
|
||||||
import { discloseResultDbProjection, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../domain/result-db-disclosure';
|
import { discloseResultDbProjection, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../domain/result-db-disclosure';
|
||||||
|
|
||||||
export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl';
|
export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl';
|
||||||
export const RESULT_DB_JSONL_SCHEMA_VERSION = '2.0.0';
|
export const RESULT_DB_JSONL_SCHEMA_VERSION = '3.0.0';
|
||||||
export const RESULT_DB_EXPORT_PAGE_SIZE = 1000;
|
export const RESULT_DB_EXPORT_PAGE_SIZE = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The export is anonymized (no customer identity), not harmless: a deviation list still
|
||||||
|
* describes an exploitable security posture. Every file carries this handling marker.
|
||||||
|
*/
|
||||||
|
export const RESULT_DB_EXPORT_HANDLING = {
|
||||||
|
classification: 'confidential-security-posture',
|
||||||
|
notice: 'Anonymized security findings without environment-specific values: observed counts, dates and durations, '
|
||||||
|
+ 'customer configuration values, customer-specific principals and baseline releases are withheld. Public baseline '
|
||||||
|
+ 'expectations (including well-known Windows principals), vendor guidance and recommendation thresholds are '
|
||||||
|
+ 'retained. Share only within the assessment engagement and with approved processors.'
|
||||||
|
} as const;
|
||||||
|
|
||||||
type ResultPageReader = (query: UnifiedResultQuery) => Promise<UnifiedResultQueryResult>;
|
type ResultPageReader = (query: UnifiedResultQuery) => Promise<UnifiedResultQueryResult>;
|
||||||
|
|
||||||
function definedQueryEntries(query: UnifiedResultQuery): Array<[string, unknown]> {
|
function definedQueryEntries(query: UnifiedResultQuery): Array<[string, unknown]> {
|
||||||
|
|
@ -27,7 +39,7 @@ function exportScope(query: UnifiedResultQuery): Record<string, unknown> {
|
||||||
function exportFilters(query: UnifiedResultQuery): Record<string, unknown> {
|
function exportFilters(query: UnifiedResultQuery): Record<string, unknown> {
|
||||||
const excluded = new Set([
|
const excluded = new Set([
|
||||||
'projectId', 'assessmentId', 'assessmentIds', 'scanRunId', 'scanRunIds', 'evaluationRunId',
|
'projectId', 'assessmentId', 'assessmentIds', 'scanRunId', 'scanRunIds', 'evaluationRunId',
|
||||||
'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo'
|
'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo', 'reviewDisposition'
|
||||||
]);
|
]);
|
||||||
return Object.fromEntries(definedQueryEntries(query)
|
return Object.fromEntries(definedQueryEntries(query)
|
||||||
.filter(([key]) => !excluded.has(key))
|
.filter(([key]) => !excluded.has(key))
|
||||||
|
|
@ -59,7 +71,8 @@ export function buildUnifiedResultJsonl(
|
||||||
format: RESULT_DB_JSONL_FORMAT,
|
format: RESULT_DB_JSONL_FORMAT,
|
||||||
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||||
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
|
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
|
||||||
generatedAt,
|
handling: RESULT_DB_EXPORT_HANDLING,
|
||||||
|
generatedOn: exportDate(generatedAt),
|
||||||
resultCount: results.length,
|
resultCount: results.length,
|
||||||
scope: exportScope(query),
|
scope: exportScope(query),
|
||||||
filters: exportFilters(query)
|
filters: exportFilters(query)
|
||||||
|
|
@ -71,9 +84,13 @@ export function buildUnifiedResultJsonl(
|
||||||
return `${lines.map(line => JSON.stringify(line)).join('\n')}\n`;
|
return `${lines.map(line => JSON.stringify(line)).join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only the calendar day travels with an export; a precise timestamp dates the assessment. */
|
||||||
|
function exportDate(generatedAt: string): string {
|
||||||
|
return generatedAt.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
export function unifiedResultJsonlFileName(generatedAt: string): string {
|
export function unifiedResultJsonlFileName(generatedAt: string): string {
|
||||||
const timestamp = generatedAt.replace(/\.\d{3}Z$/, 'Z').replaceAll(':', '-');
|
return `assessor-result-db-filtered-${exportDate(generatedAt)}.jsonl`;
|
||||||
return `assessor-result-db-filtered-${timestamp}.jsonl`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryAllUnifiedResults(
|
export async function queryAllUnifiedResults(
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,32 @@ export function powerShellDisplayLines(command: string): string[] {
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First prose paragraph of a native result text, retained verbatim. Markdown headings, tables,
|
||||||
|
* rules and blank lines are skipped; nothing is shortened or rephrased.
|
||||||
|
*/
|
||||||
|
export function resultLead(value?: string): string | undefined {
|
||||||
|
if (!value?.trim()) return undefined;
|
||||||
|
// A bare status word ("Failed") is the status, not a result statement.
|
||||||
|
if (/^(?:passed|failed|skipped|error|investigate|not ?run|not ?tested)$/i.test(value.trim())) return undefined;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const line of value.split(/\r?\n/)) {
|
||||||
|
// Exports that lost their line breaks keep the heading, table or next heading on the same line;
|
||||||
|
// the statement ends where such a structure begins.
|
||||||
|
const trimmed = line.trim().replace(/^#{1,6}\s+Test result\s+/i, '');
|
||||||
|
const structure = /(?:^|\s)(?:\|\s|#{1,6}\s)/.exec(trimmed);
|
||||||
|
const text = (structure ? trimmed.slice(0, structure.index) : trimmed).trim();
|
||||||
|
const structural = !text || /^[-*_]{3,}$/.test(text);
|
||||||
|
if (structural) {
|
||||||
|
if (lines.length) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(text);
|
||||||
|
if (structure) break;
|
||||||
|
}
|
||||||
|
return lines.join(' ') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function findingFirstRead(
|
export function findingFirstRead(
|
||||||
record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome
|
record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome
|
||||||
): FindingFirstRead | undefined {
|
): FindingFirstRead | undefined {
|
||||||
|
|
@ -166,6 +192,15 @@ export function findingFirstRead(
|
||||||
const rows: FirstReadRow[] = [];
|
const rows: FirstReadRow[] = [];
|
||||||
const result = fieldFor(record, definition.resultHeaders);
|
const result = fieldFor(record, definition.resultHeaders);
|
||||||
if (result) rows.push({ label: 'Result', value: result.value.trim() });
|
if (result) rows.push({ label: 'Result', value: result.value.trim() });
|
||||||
|
else {
|
||||||
|
// `Result` is a result statement only next to a separate status column (Maester export variants).
|
||||||
|
const hasSeparateStatus = record.fields.some(item => /^status$/i.test(normalize(item.label)) && item.value.trim());
|
||||||
|
const lead = (definition.resultLeadHeaders ?? [])
|
||||||
|
.filter(pattern => hasSeparateStatus || !pattern.test('Result'))
|
||||||
|
.map(pattern => resultLead(fieldFor(record, [pattern])?.value))
|
||||||
|
.find(Boolean);
|
||||||
|
if (lead) rows.push({ label: 'Result', value: lead });
|
||||||
|
}
|
||||||
|
|
||||||
const resultRows = linkedSheets.flatMap(sheet => sheet.analysis.records);
|
const resultRows = linkedSheets.flatMap(sheet => sheet.analysis.records);
|
||||||
if (definition.criterionEvidence) {
|
if (definition.criterionEvidence) {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import { browserDownloadAdapter } from '../../platform/browser/browser-download-
|
||||||
import { downloadText } from '../../exports/text-download';
|
import { downloadText } from '../../exports/text-download';
|
||||||
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
|
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
|
||||||
import { hasSelectedTimeslotScope } from '../../app/work-scope';
|
import { hasSelectedTimeslotScope } from '../../app/work-scope';
|
||||||
import { baselineVariantSummary } from '../../evaluation/policy-baseline-variants';
|
|
||||||
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
|
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
|
||||||
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
||||||
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
|
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
|
||||||
|
import type { ResultDbMaterializationStatus } from '../../domain/result-db-materialization';
|
||||||
|
|
||||||
const assessorApi = new AssessorApiClient();
|
const assessorApi = new AssessorApiClient();
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
@ -24,12 +24,16 @@ interface ResultsBrowserProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
scanRunIds?: string[];
|
scanRunIds?: string[];
|
||||||
assessmentIds?: string[];
|
assessmentIds?: string[];
|
||||||
|
/** Work records holding the original of a row; the Result DB itself never shows originals. */
|
||||||
|
workRecordIds?(row: UnifiedResultProjection): string[];
|
||||||
|
onOpenInWork?(recordIds: string[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds }: ResultsBrowserProps) {
|
export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds, workRecordIds, onOpenInWork }: ResultsBrowserProps) {
|
||||||
const [filters, setFilters] = useState<UnifiedResultQuery>(() => ({ ...DEFAULT_RESULT_DB_FILTERS }));
|
const [filters, setFilters] = useState<UnifiedResultQuery>(() => ({ ...DEFAULT_RESULT_DB_FILTERS }));
|
||||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
const [limit, setLimit] = useState(PAGE_SIZE);
|
||||||
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
|
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
|
||||||
|
const [resultDbStatus, setResultDbStatus] = useState<ResultDbMaterializationStatus | null>(null);
|
||||||
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
|
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
@ -59,6 +63,8 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
||||||
]);
|
]);
|
||||||
setFacetResult(facets);
|
setFacetResult(facets);
|
||||||
setQueryResult(results);
|
setQueryResult(results);
|
||||||
|
// Read after the rows: serving them guarantees the stored Result DB is current.
|
||||||
|
setResultDbStatus(await assessorApi.getResultDbStatus().catch(() => null));
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : 'Results could not be loaded.');
|
setError(reason instanceof Error ? reason.message : 'Results could not be loaded.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -128,7 +134,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
||||||
<span><strong>Processing readiness:</strong> {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended.</span>
|
<span><strong>Processing readiness:</strong> {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended.</span>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="page-actions-row">
|
<div className="page-actions-row">
|
||||||
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}</p>
|
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}{resultDbStatus && <><br/><span className="results-db-status" data-state={resultDbStatus.state}>{resultDbStatusText(resultDbStatus)}</span></>}</p>
|
||||||
<div className="page-action-buttons">
|
<div className="page-action-buttons">
|
||||||
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
|
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
|
||||||
<button className="btn primary" type="button" disabled={loading || exporting || !queryResult?.total}
|
<button className="btn primary" type="button" disabled={loading || exporting || !queryResult?.total}
|
||||||
|
|
@ -137,9 +143,10 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
||||||
</div>
|
</div>
|
||||||
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
|
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
|
||||||
<div className="results-browser-row results-browser-head" role="row">
|
<div className="results-browser-row results-browser-head" role="row">
|
||||||
<span>Title</span><span>Source Rule ID</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span><span>Review</span>
|
<span>Title</span><span>Source Rule ID</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span>
|
||||||
</div>
|
</div>
|
||||||
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
|
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
|
||||||
|
workRecordIds={workRecordIds?.(item) ?? []} onOpenInWork={onOpenInWork}
|
||||||
expanded={expandedId === item.resultProjectionId}
|
expanded={expandedId === item.resultProjectionId}
|
||||||
onToggle={() => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)}
|
onToggle={() => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
@ -149,7 +156,13 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: UnifiedResultProjection; expanded: boolean; onToggle(): void }) {
|
export function ResultRow({ item: sourceItem, expanded, onToggle, workRecordIds = [], onOpenInWork }: {
|
||||||
|
item: UnifiedResultProjection;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle(): void;
|
||||||
|
workRecordIds?: string[];
|
||||||
|
onOpenInWork?(recordIds: string[]): void;
|
||||||
|
}) {
|
||||||
const item = removeResultDbScopeIdentifiers(discloseResultDbProjection(sourceItem));
|
const item = removeResultDbScopeIdentifiers(discloseResultDbProjection(sourceItem));
|
||||||
const environment = item.sourceKind === 'environment-observation';
|
const environment = item.sourceKind === 'environment-observation';
|
||||||
const policyObservation = item.sourceKind === 'policy-observation';
|
const policyObservation = item.sourceKind === 'policy-observation';
|
||||||
|
|
@ -166,9 +179,7 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
|
||||||
<span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
|
<span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
|
||||||
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
|
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
|
||||||
<span data-label="Tool">{resultSourceToolLabel(item.sourceTool)}</span>
|
<span data-label="Tool">{resultSourceToolLabel(item.sourceTool)}</span>
|
||||||
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span>
|
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span> </button>
|
||||||
<span data-label="Review">{environment ? '–' : item.reviewStatus}</span>
|
|
||||||
</button>
|
|
||||||
{expanded && <div className="results-browser-row-detail">
|
{expanded && <div className="results-browser-row-detail">
|
||||||
{environment && <div className="results-environment-value">
|
{environment && <div className="results-environment-value">
|
||||||
<strong>{item.environmentProperty}</strong>
|
<strong>{item.environmentProperty}</strong>
|
||||||
|
|
@ -181,8 +192,6 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
|
||||||
{item.description && <p>{item.description}</p>}
|
{item.description && <p>{item.description}</p>}
|
||||||
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
|
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
|
||||||
{policyComparison && <div className="results-environment-value">
|
{policyComparison && <div className="results-environment-value">
|
||||||
<strong>Configured customer value</strong>
|
|
||||||
<p>{displayPolicyValue(item.policyActualValue)}</p>
|
|
||||||
<strong>Expected baseline value</strong>
|
<strong>Expected baseline value</strong>
|
||||||
<p>{displayPolicyValue(item.policyBaselineValue)}</p>
|
<p>{displayPolicyValue(item.policyBaselineValue)}</p>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
@ -207,10 +216,10 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
|
||||||
<li>Baseline: {item.policyBaselineLabel}</li>
|
<li>Baseline: {item.policyBaselineLabel}</li>
|
||||||
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
|
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
|
||||||
{item.policyBaselineVariantAssessment ? <li>
|
{item.policyBaselineVariantAssessment ? <li>
|
||||||
{baselineVariantSummary(item.policyBaselineVariantAssessment)}
|
{disclosedBaselineDecision(item.policyBaselineVariantAssessment.decision)}
|
||||||
<p>No effective device state or GPO application is established by this backup comparison.</p>
|
<p>No effective device state or GPO application is established by this backup comparison.</p>
|
||||||
<ul>{item.policyBaselineVariantAssessment.variants.map(variant => <li key={variant.settingId}>
|
<ul>{item.policyBaselineVariantAssessment.variants.map(variant => <li key={variant.settingId}>
|
||||||
Setting reference: {variant.settingId} · Value comparison: {variant.comparator.relation}
|
Value comparison: {variant.comparator.relation}
|
||||||
<p className="policy-guidance-text">Expected: {displayPolicyValue(variant.comparator.baseline)}</p>
|
<p className="policy-guidance-text">Expected: {displayPolicyValue(variant.comparator.baseline)}</p>
|
||||||
<p className="policy-guidance-text">Original baseline value: {displayPolicyValue(variant.baselineValues)}</p>
|
<p className="policy-guidance-text">Original baseline value: {displayPolicyValue(variant.baselineValues)}</p>
|
||||||
</li>)}</ul>
|
</li>)}</ul>
|
||||||
|
|
@ -223,6 +232,7 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
|
||||||
</>}
|
</>}
|
||||||
<li>Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}</li>
|
<li>Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<DisclosureTraceDetail item={item} workRecordIds={workRecordIds} onOpenInWork={onOpenInWork}/>
|
||||||
</div>}
|
</div>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +249,14 @@ function displayPolicyValue(value: unknown): string {
|
||||||
return value === undefined ? '–' : formatPolicyComparisonValue(JSON.stringify(value));
|
return value === undefined ? '–' : formatPolicyComparisonValue(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result DB collapses identical baseline expectations, so it states the decision without a variant count. */
|
||||||
|
function disclosedBaselineDecision(decision: string): string {
|
||||||
|
if (decision === 'all-pass') return 'Matches every assessed baseline expectation (backup configuration only)';
|
||||||
|
if (decision === 'all-fail') return 'Differs from every assessed baseline expectation (backup configuration only)';
|
||||||
|
if (decision === 'mixed') return 'Review required: baseline expectations lead to different results';
|
||||||
|
return 'Review required: comparison evidence or applicability is incomplete';
|
||||||
|
}
|
||||||
|
|
||||||
function displayPolicyIdentity(item: UnifiedResultProjection): string | undefined {
|
function displayPolicyIdentity(item: UnifiedResultProjection): string | undefined {
|
||||||
const identity = item.policyIdentityKey ?? item.sourceRuleId;
|
const identity = item.policyIdentityKey ?? item.sourceRuleId;
|
||||||
return identity && !/^gpo-policy-identity-[0-9a-f]+$/i.test(identity) ? identity : undefined;
|
return identity && !/^gpo-policy-identity-[0-9a-f]+$/i.test(identity) ? identity : undefined;
|
||||||
|
|
@ -251,3 +269,50 @@ function processingStatusLabel(value: string | undefined): string {
|
||||||
if (value === 'incompatible') return 'Incompatible';
|
if (value === 'incompatible') return 'Incompatible';
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const traceFieldLabels: Record<string, string> = {
|
||||||
|
title: 'Title', description: 'Description', recommendation: 'Recommendation', sourceRuleId: 'Source Rule ID',
|
||||||
|
policyBaselineLabel: 'Baseline', policyBaselineValue: 'Expected baseline value',
|
||||||
|
policyBaselineVariantAssessment: 'Baseline expectations', environmentNormalizedValue: 'Value', categories: 'Categories'
|
||||||
|
};
|
||||||
|
|
||||||
|
function resultDbStatusText(status: ResultDbMaterializationStatus): string {
|
||||||
|
if (status.state === 'not-built') return 'Result DB not built yet';
|
||||||
|
const checked = status.materializedAt ? status.materializedAt.slice(0, 16).replace('T', ' ') : '–';
|
||||||
|
return [
|
||||||
|
status.state === 'current' ? `Checked ${checked} UTC` : `Outdated: checked ${checked} UTC, rebuilding`,
|
||||||
|
`policy ${status.disclosurePolicyVersion ?? '–'}`,
|
||||||
|
`identity guard ${status.identityGuardVersion ?? '–'}`,
|
||||||
|
`${status.rowCount.toLocaleString('en-US')} rows`,
|
||||||
|
`${status.redactionCount.toLocaleString('en-US')} redactions`,
|
||||||
|
`${status.guardWithheldRowCount.toLocaleString('en-US')} rows with withheld fields`
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content-free trace of the stored row: what was redacted and withheld, never the original. */
|
||||||
|
function DisclosureTraceDetail({ item, workRecordIds, onOpenInWork }: {
|
||||||
|
item: UnifiedResultProjection;
|
||||||
|
workRecordIds: string[];
|
||||||
|
onOpenInWork?(recordIds: string[]): void;
|
||||||
|
}) {
|
||||||
|
const trace = item.disclosureTrace;
|
||||||
|
if (!trace) return null;
|
||||||
|
const byField = new Map<string, string[]>();
|
||||||
|
for (const entry of trace.redactions) {
|
||||||
|
byField.set(entry.field, [...(byField.get(entry.field) ?? []), `${entry.rule} × ${entry.count.toLocaleString('en-US')}`]);
|
||||||
|
}
|
||||||
|
const hasOriginal = item.sourceKind === 'scanner-finding' || item.sourceKind === 'policy-evaluation';
|
||||||
|
return <div className="results-disclosure-trace">
|
||||||
|
<strong>Checked content</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Policy {trace.disclosurePolicyVersion} · identity guard {trace.identityGuardVersion}</li>
|
||||||
|
{[...byField].map(([field, rules]) => <li key={field}>Redacted in {traceFieldLabels[field] ?? field}: {rules.join(', ')}</li>)}
|
||||||
|
{!byField.size && <li>No redaction in the disclosed fields</li>}
|
||||||
|
{trace.withheldFields.length > 0 && <li>Withheld fields: {trace.withheldFields.join(', ')}</li>}
|
||||||
|
{trace.guardWithheldFields.length > 0 && <li>Withheld by identity guard: {trace.guardWithheldFields.map(field => traceFieldLabels[field] ?? field).join(', ')}</li>}
|
||||||
|
</ul>
|
||||||
|
{hasOriginal && (workRecordIds.length && onOpenInWork
|
||||||
|
? <button className="btn" type="button" onClick={() => onOpenInWork(workRecordIds)}>Open original in Work</button>
|
||||||
|
: <p>The original is not part of the loaded Work scope.</p>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ interface ResultsFilterPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MultiSelectFacetId = 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState'
|
type MultiSelectFacetId = 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState'
|
||||||
| 'resultType' | 'processingStatus' | 'reviewDisposition' | 'lifecycleState'
|
| 'resultType' | 'processingStatus' | 'lifecycleState'
|
||||||
| 'environmentState' | 'environmentProperty' | 'environmentSubjectType' | 'policyClassification';
|
| 'environmentState' | 'environmentProperty' | 'environmentSubjectType' | 'policyClassification';
|
||||||
|
|
||||||
const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> = {
|
const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> = {
|
||||||
|
|
@ -19,7 +19,6 @@ const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> =
|
||||||
technicalExecutionState: 'technicalExecutionState',
|
technicalExecutionState: 'technicalExecutionState',
|
||||||
resultType: 'resultType',
|
resultType: 'resultType',
|
||||||
processingStatus: 'processingStatus',
|
processingStatus: 'processingStatus',
|
||||||
reviewDisposition: 'reviewDisposition',
|
|
||||||
lifecycleState: 'lifecycleState',
|
lifecycleState: 'lifecycleState',
|
||||||
environmentState: 'environmentState',
|
environmentState: 'environmentState',
|
||||||
environmentProperty: 'environmentProperty',
|
environmentProperty: 'environmentProperty',
|
||||||
|
|
@ -27,7 +26,7 @@ const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> =
|
||||||
policyClassification: 'policyClassification'
|
policyClassification: 'policyClassification'
|
||||||
};
|
};
|
||||||
|
|
||||||
const READONLY_FACET_IDS = new Set(['reviewStatus', 'effectiveState']);
|
const READONLY_FACET_IDS = new Set(['effectiveState']);
|
||||||
|
|
||||||
function toggleValue(current: string[], value: string): string[] | undefined {
|
function toggleValue(current: string[], value: string): string[] | undefined {
|
||||||
const set = new Set(current);
|
const set = new Set(current);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { ImportedDataset } from '../../domain/dataset';
|
||||||
|
import type { UnifiedResultProjection } from '../../domain/unified-result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work records holding the original of a Result DB row (user decision 2026-09-28: the Result DB shows
|
||||||
|
* only the checked content; the original stays in Work). Scanner rows match their finding occurrence;
|
||||||
|
* GPO comparison rows match the Work setting of the same scope and technical setting identity.
|
||||||
|
*/
|
||||||
|
export function workRecordIdsForResult(dataset: ImportedDataset | null | undefined, row: UnifiedResultProjection): string[] {
|
||||||
|
if (!dataset) return [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const sheet of dataset.sheets) {
|
||||||
|
for (const record of sheet.analysis.records) {
|
||||||
|
if (row.sourceKind === 'scanner-finding' && record.findingOccurrenceId === row.sourceResultId) ids.add(record.id);
|
||||||
|
if (row.sourceKind === 'policy-evaluation' && row.sourceRuleId && record.id.startsWith('gpo-work-setting:')) {
|
||||||
|
try {
|
||||||
|
const key = JSON.parse(decodeURIComponent(record.id.slice('gpo-work-setting:'.length))) as unknown[];
|
||||||
|
if (key[2] === row.policyScope && key[3] === row.sourceRuleId) ids.add(record.id);
|
||||||
|
} catch {
|
||||||
|
// Not an evaluated setting key; unevaluated diagnostics have no comparison original.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
} from '../domain/canonical-model';
|
} from '../domain/canonical-model';
|
||||||
import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
|
import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
|
||||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||||
|
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||||
import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison';
|
import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison';
|
||||||
import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow';
|
import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow';
|
||||||
import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence';
|
import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence';
|
||||||
|
|
@ -210,6 +211,8 @@ export interface AssessorRepository {
|
||||||
queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
|
queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
|
||||||
queryUnifiedResultFacets(query?: UnifiedResultQuery): Promise<UnifiedResultFacetResult>;
|
queryUnifiedResultFacets(query?: UnifiedResultQuery): Promise<UnifiedResultFacetResult>;
|
||||||
getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null>;
|
getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null>;
|
||||||
|
/** State of the materialized, disclosure-checked Result DB (see result-db-materialization). */
|
||||||
|
getResultDbStatus(): Promise<ResultDbMaterializationStatus>;
|
||||||
listResultCategories(query?: UnifiedResultQuery): Promise<UnifiedResultCategory[]>;
|
listResultCategories(query?: UnifiedResultQuery): Promise<UnifiedResultCategory[]>;
|
||||||
getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise<EnvironmentSnapshot>;
|
getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise<EnvironmentSnapshot>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ import {
|
||||||
type UnifiedResultQueryResult
|
type UnifiedResultQueryResult
|
||||||
} from '../domain/unified-result';
|
} from '../domain/unified-result';
|
||||||
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../domain/result-db-disclosure';
|
import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../domain/result-db-disclosure';
|
||||||
|
import { buildResultDbIdentityGuards, type ResultDbIdentityGuard } from '../domain/result-db-identity-guard';
|
||||||
|
import { isResultDbCurrent, materializeResultDb, type MaterializedResultDb, type ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||||
import { createPolicyConsistencyUnifiedResults } from '../evaluation/policy-consistency-results';
|
import { createPolicyConsistencyUnifiedResults } from '../evaluation/policy-consistency-results';
|
||||||
import type {
|
import type {
|
||||||
AssessorRepository,
|
AssessorRepository,
|
||||||
|
|
@ -294,6 +296,7 @@ class LocalImportSession implements ImportSession {
|
||||||
export class LocalAssessorRepository implements AssessorRepository {
|
export class LocalAssessorRepository implements AssessorRepository {
|
||||||
private state = emptyState();
|
private state = emptyState();
|
||||||
private stateRevision = 0;
|
private stateRevision = 0;
|
||||||
|
private resultDb?: MaterializedResultDb;
|
||||||
private readonly activeImportSessions = new Set<string>();
|
private readonly activeImportSessions = new Set<string>();
|
||||||
|
|
||||||
get revision(): number {
|
get revision(): number {
|
||||||
|
|
@ -647,7 +650,42 @@ export class LocalAssessorRepository implements AssessorRepository {
|
||||||
? createPolicyConsistencyUnifiedResults(currentPolicyObservations)
|
? createPolicyConsistencyUnifiedResults(currentPolicyObservations)
|
||||||
: [];
|
: [];
|
||||||
const results = [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
|
const results = [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
|
||||||
return audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection);
|
if (audience === 'internal-analysis') return results;
|
||||||
|
return this.materializedResultDb(results, currentPolicyObservations).rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory counterpart of the stored Result DB: rebuilt once per state revision and served
|
||||||
|
* unchanged to every Result DB read until the state changes again.
|
||||||
|
*/
|
||||||
|
private materializedResultDb(
|
||||||
|
sourceResults: UnifiedResultProjection[],
|
||||||
|
currentPolicyObservations: UnifiedResultProjection[]
|
||||||
|
): MaterializedResultDb {
|
||||||
|
if (this.resultDb && isResultDbCurrent(this.resultDb.status, this.stateRevision)) return this.resultDb;
|
||||||
|
const guards = this.identityGuards([...sourceResults, ...currentPolicyObservations]);
|
||||||
|
this.resultDb = materializeResultDb(sourceResults, guards, this.stateRevision, new Date().toISOString());
|
||||||
|
return this.resultDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResultDbStatus(): Promise<ResultDbMaterializationStatus> {
|
||||||
|
const status = this.resultDb?.status;
|
||||||
|
if (!status) {
|
||||||
|
return { state: 'not-built', repositoryRevision: this.stateRevision, rowCount: 0, redactionCount: 0, guardWithheldRowCount: 0 };
|
||||||
|
}
|
||||||
|
return clone({ ...status, repositoryRevision: this.stateRevision,
|
||||||
|
state: isResultDbCurrent(status, this.stateRevision) ? 'current' : 'outdated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client identity guards from the complete, unfiltered internal state (see result-db-identity-guard). */
|
||||||
|
private identityGuards(internalResults: UnifiedResultProjection[]): Map<string, ResultDbIdentityGuard> {
|
||||||
|
// Built on every read: the in-memory state is small, and a stale guard would fail open.
|
||||||
|
const environmentObservations = this.state.environmentObservations.flatMap(observation => {
|
||||||
|
const scanRun = this.state.scanRuns.find(item => item.id === observation.scanRunId);
|
||||||
|
const assessment = scanRun ? this.state.assessments.find(item => item.id === scanRun.assessmentId) : undefined;
|
||||||
|
return assessment ? [{ projectId: assessment.projectId, observation }] : [];
|
||||||
|
});
|
||||||
|
return buildResultDbIdentityGuards({ projects: this.state.projects, environmentObservations, internalResults });
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,11 @@ export const maesterDefinition: ToolProfileDefinition = {
|
||||||
],
|
],
|
||||||
normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
|
normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
|
||||||
presentation: {
|
presentation: {
|
||||||
// The native test result stays in Results only (user decision 2026-09-25); the
|
// User decision 2026-09-28 supersedes the Results-only rule of 2026-09-25 for the lead: the first
|
||||||
// first read counts the rows Maester itself marks and links its documentation.
|
// prose paragraph of the native test result ("You have 3 of 16 users ...") is the problem statement,
|
||||||
|
// verbatim. Its tables and the complete text stay in Results; source-marked rows are still counted.
|
||||||
firstRead: {
|
firstRead: {
|
||||||
|
resultLeadHeaders: [/^resultdetail$/i, /^test result$/i, /^result$/i],
|
||||||
statusRowCounts: true,
|
statusRowCounts: true,
|
||||||
nextStepLinkHeaders: [/^helpurl$/i],
|
nextStepLinkHeaders: [/^helpurl$/i],
|
||||||
// Maester descriptions carry their remediation as a native `#### Remediation action:` subsection.
|
// Maester descriptions carry their remediation as a native `#### Remediation action:` subsection.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"passwords": "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpsb/0b40db09-d95d-40a6-8467-32aedec8140c",
|
"passwords": "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpsb/0b40db09-d95d-40a6-8467-32aedec8140c",
|
||||||
"asr": "https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-overview",
|
"asr": "https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-overview",
|
||||||
"maester": "https://maester.dev/docs/tests/maester/",
|
"maester": "https://maester.dev/docs/tests/maester/",
|
||||||
"pingcastle": "https://www.pingcastle.com/PingCastleFiles/ad_hc_rules_list.html"
|
"pingcastle": "https://www.pingcastle.com/PingCastleFiles/ad_hc_rules_list.html",
|
||||||
|
"microsoftIdentifiers": "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions"
|
||||||
},
|
},
|
||||||
"registryTargets": [
|
"registryTargets": [
|
||||||
"SOFTWARE\\Policies\\Microsoft\\Windows Defender\\PUAProtection",
|
"SOFTWARE\\Policies\\Microsoft\\Windows Defender\\PUAProtection",
|
||||||
|
|
@ -757,5 +758,15 @@
|
||||||
"a8f5898e-1dc8-49a9-9878-85004b8a61e6",
|
"a8f5898e-1dc8-49a9-9878-85004b8a61e6",
|
||||||
"92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b",
|
"92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b",
|
||||||
"c1db55ab-c21a-4637-bb3f-a12568109d35"
|
"c1db55ab-c21a-4637-bb3f-a12568109d35"
|
||||||
|
],
|
||||||
|
"microsoftIdentifiers": [
|
||||||
|
"499b84ac-1321-427f-aa17-267ca6975798",
|
||||||
|
"797f4846-ba00-4fd7-ba43-dac1f8f63013",
|
||||||
|
"14d82eec-204b-4c2f-b7e8-296a70dab67e",
|
||||||
|
"de8bc8b5-d9f9-48b1-a8ad-b748da725064",
|
||||||
|
"62e90394-69f5-4237-9190-012177145e10",
|
||||||
|
"a0b1b346-4d3e-4e8b-98f8-753987be4970",
|
||||||
|
"10dae51f-b6af-4016-8d66-8c2a99b929b3",
|
||||||
|
"2af84b1e-32c8-42b7-82bc-daa82404023b"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -367,10 +367,13 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
.client-info-card-details { display: grid; align-content: start; gap: 0; min-width: 0; padding: 0 var(--space-5) var(--space-3); }
|
.client-info-card-details { display: grid; align-content: start; gap: 0; min-width: 0; padding: 0 var(--space-5) var(--space-3); }
|
||||||
.client-info-card-extra { display: grid; align-content: start; min-width: 0; padding: 0 var(--space-5) var(--space-4); border-top: var(--border-hair) solid var(--line); }
|
.client-info-card-extra { display: grid; align-content: start; min-width: 0; padding: 0 var(--space-5) var(--space-4); border-top: var(--border-hair) solid var(--line); }
|
||||||
|
|
||||||
/* Keep every environment tile on one readable track; overflow stays inside the segment. */
|
/* Keep the six overview tiles in one row; narrower panels scroll horizontally. */
|
||||||
.client-environment-grid { display: grid; grid-auto-flow: column; grid-auto-columns: min(var(--tile-double-compact-width), 100%); grid-template-rows: auto; align-content: start; justify-content: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
|
.client-environment-grid { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); align-items: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
|
||||||
.client-environment-grid > .client-env-group,
|
.client-environment-grid > .client-env-group,
|
||||||
.client-environment-grid > .client-os-inventory { display: flex; flex-direction: column; min-width: 0; width: min(100%, var(--tile-double-compact-width)); min-height: var(--tile-double-compact-width); padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
.client-environment-grid > .client-os-inventory { display: flex; flex-direction: column; min-width: 0; aspect-ratio: 1; container: environment-tile / inline-size; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); overflow: hidden; }
|
||||||
|
@container environment-tile (max-width: 17rem) {
|
||||||
|
.client-env-facts > div:nth-child(n + 3) { display: none; }
|
||||||
|
}
|
||||||
.client-environment-grid > section > header { align-items: flex-start; gap: var(--space-1); }
|
.client-environment-grid > section > header { align-items: flex-start; gap: var(--space-1); }
|
||||||
.client-command-center .client-environment-grid > .client-os-inventory > header { margin-bottom: 0; }
|
.client-command-center .client-environment-grid > .client-os-inventory > header { margin-bottom: 0; }
|
||||||
.client-environment-grid .client-environment-results-button { margin-top: 0; margin-inline-start: auto; }
|
.client-environment-grid .client-environment-results-button { margin-top: 0; margin-inline-start: auto; }
|
||||||
|
|
@ -378,7 +381,6 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
||||||
.client-environment-grid > section > .client-env-facts > div { min-height: 0; padding: var(--space-1) var(--space-2); border: var(--border-hair) solid var(--client-info-line); background: var(--tile-surface); }
|
.client-environment-grid > section > .client-env-facts > div { min-height: 0; padding: var(--space-1) var(--space-2); border: var(--border-hair) solid var(--client-info-line); background: var(--tile-surface); }
|
||||||
.client-environment-grid > section > .client-env-facts > div.is-wide { grid-column: 1 / -1; }
|
.client-environment-grid > section > .client-env-facts > div.is-wide { grid-column: 1 / -1; }
|
||||||
.client-environment-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); }
|
.client-environment-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); }
|
||||||
.client-environment-grid > section > .client-env-facts dd { overflow-wrap: normal; word-break: normal; }
|
|
||||||
.client-environment-grid .client-env-group-title,
|
.client-environment-grid .client-env-group-title,
|
||||||
.client-command-center .client-environment-grid > .client-os-inventory > header h3 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
.client-command-center .client-environment-grid > .client-os-inventory > header h3 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||||
.client-env-group { min-width: 0; }
|
.client-env-group { min-width: 0; }
|
||||||
|
|
@ -907,7 +909,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
||||||
.results-browser-table { border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); width: 100%; overflow-x: auto; }
|
.results-browser-table { border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); width: 100%; overflow-x: auto; }
|
||||||
.results-browser-row {
|
.results-browser-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 1fr) 160px 80px 120px 140px 50px 90px 110px 150px 110px;
|
grid-template-columns: minmax(280px, 1fr) 160px 80px 120px 140px 50px 90px 110px 150px;
|
||||||
gap: var(--legacy-space-3); align-items: center; padding: var(--legacy-space-2) var(--legacy-space-3); border-top: var(--border-hair) solid var(--line); font-size: var(--ui-control-size); min-width: fit-content;
|
gap: var(--legacy-space-3); align-items: center; padding: var(--legacy-space-2) var(--legacy-space-3); border-top: var(--border-hair) solid var(--line); font-size: var(--ui-control-size); min-width: fit-content;
|
||||||
}
|
}
|
||||||
.results-browser-row:first-child { border-top: 0; }
|
.results-browser-row:first-child { border-top: 0; }
|
||||||
|
|
@ -927,6 +929,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
||||||
.results-browser-row span[data-label="Source Rule ID"] { overflow: visible; white-space: normal; text-overflow: clip; line-height: 1.35; overflow-wrap: anywhere; }
|
.results-browser-row span[data-label="Source Rule ID"] { overflow: visible; white-space: normal; text-overflow: clip; line-height: 1.35; overflow-wrap: anywhere; }
|
||||||
.results-browser-row-detail { grid-column: 1 / -1; min-width: 0; padding: var(--legacy-space-3); border-top: var(--border-hair) dashed var(--line); color: var(--muted); font-size: var(--ui-control-size); line-height: 1.55; overflow-wrap: anywhere; }
|
.results-browser-row-detail { grid-column: 1 / -1; min-width: 0; padding: var(--legacy-space-3); border-top: var(--border-hair) dashed var(--line); color: var(--muted); font-size: var(--ui-control-size); line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
.results-browser-row-detail ul { min-width: 0; margin: var(--legacy-space-2) 0 0; padding: 0; list-style: none; display: grid; gap: var(--legacy-space-1); }
|
.results-browser-row-detail ul { min-width: 0; margin: var(--legacy-space-2) 0 0; padding: 0; list-style: none; display: grid; gap: var(--legacy-space-1); }
|
||||||
|
.results-disclosure-trace { margin-top: var(--legacy-space-3); padding-top: var(--legacy-space-2); border-top: var(--border-hair) dashed var(--line); display: grid; gap: var(--legacy-space-2); justify-items: start; }
|
||||||
|
.results-disclosure-trace p { margin: 0; }
|
||||||
|
.results-db-status[data-state="outdated"], .results-db-status[data-state="not-built"] { color: var(--sev-medium); }
|
||||||
.results-environment-value { color: var(--text); font-size: var(--fs-lg); line-height: var(--lh-prose); }
|
.results-environment-value { color: var(--text); font-size: var(--fs-lg); line-height: var(--lh-prose); }
|
||||||
.results-environment-value strong { color: var(--heading); }
|
.results-environment-value strong { color: var(--heading); }
|
||||||
.results-environment-value p { margin: var(--legacy-space-1) 0 var(--legacy-space-3); overflow-wrap: anywhere; white-space: pre-wrap; }
|
.results-environment-value p { margin: var(--legacy-space-1) 0 var(--legacy-space-3); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
Beschriftung liegen gemeinsam in derselben klick- und fokussierbaren Aktion. */
|
Beschriftung liegen gemeinsam in derselben klick- und fokussierbaren Aktion. */
|
||||||
.tenant-back .triangle-icon { width: 28px; height: 28px; fill: var(--band-surface); }
|
.tenant-back .triangle-icon { width: 28px; height: 28px; fill: var(--band-surface); }
|
||||||
.db-structure-card-toggle { display: grid; place-items: center; width: 18px; height: 18px; }
|
.db-structure-card-toggle { display: grid; place-items: center; width: 18px; height: 18px; }
|
||||||
.results-browser-title { display: flex; align-items: center; gap: 7px; }
|
.results-browser-title { display: flex; align-items: center; gap: 7px; text-align: left; }
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
||||||
|
|
||||||
describe('ASSESSOR version in the brand header', () => {
|
describe('ASSESSOR version in the brand header', () => {
|
||||||
it('shows the centrally maintained product version and opens About from the brand', () => {
|
it('shows the centrally maintained product version and opens About from the brand', () => {
|
||||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.8.7');
|
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.0');
|
||||||
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
|
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
|
||||||
expect(app).toContain('aria-label="About ASSESSOR"');
|
expect(app).toContain('aria-label="About ASSESSOR"');
|
||||||
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
import { discloseResultDbProjection, removeResultDbScopeIdentifiers, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../../src/domain/result-db-disclosure';
|
import {
|
||||||
|
discloseResultDbProjection, discloseResultDbProjectionWithTrace, removeResultDbScopeIdentifiers, RESULT_DB_DISCLOSURE_POLICY_VERSION
|
||||||
|
} from '../../src/domain/result-db-disclosure';
|
||||||
|
|
||||||
function unsafeScanner(): UnifiedResultProjection {
|
function unsafeScanner(): UnifiedResultProjection {
|
||||||
return {
|
return {
|
||||||
|
|
@ -41,6 +43,289 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('withholds review status, disposition and priority from the Result DB projection', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), reviewDisposition: 'accepted', reviewPriority: 'high'
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of ['reviewStatus', 'reviewDisposition', 'reviewPriority', 'reviewOwner', 'reviewDueDate',
|
||||||
|
'sourceToolVersion', 'sourceObservedAt', 'affectedAsset', 'sourceLocator']) {
|
||||||
|
expect(Object.keys(disclosed)).not.toContain(key);
|
||||||
|
}
|
||||||
|
expect(discloseResultDbProjection(disclosed)).toEqual(disclosed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces environment fingerprints in scanner prose but keeps public technical tokens', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'A-NotEnoughDC',
|
||||||
|
title: 'The number of DCs is too small to provide redundancy: 1 DC',
|
||||||
|
description: 'Last AD backup 55 days ago on 2026-08-04 at 13:37; built-in admin used 0 day(s) ago. '
|
||||||
|
+ '15 accounts and 1.015 objects, 3,250 computers, 42% of users, observed 28.09.2026.',
|
||||||
|
recommendation: 'Disable NTLMv1 and TLS 1.0 on Windows Server 2016 and Windows 10, see MT.1024, '
|
||||||
|
+ 'CVE-2021-34527, T1558.003, KB5005413, event ID 4769, port 445, 64-bit and S-1-5-32-544.'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disclosed.title).toBe('The number of DCs is too small to provide redundancy: [count] DC');
|
||||||
|
expect(disclosed.description).toBe('Last AD backup [duration] ago on [date] at [time]; built-in admin used [duration] ago. '
|
||||||
|
+ '[count] accounts and [count] objects, [count] computers, [count]% of users, observed [date].');
|
||||||
|
expect(disclosed.recommendation).toBe('Disable NTLMv1 and TLS 1.0 on Windows Server 2016 and Windows 10, see MT.1024, '
|
||||||
|
+ 'CVE-2021-34527, T1558.003, KB5005413, event ID 4769, port 445, 64-bit and S-1-5-32-544.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps numbered remediation steps intact', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'purple-knight', title: 'Synthetic',
|
||||||
|
recommendation: '1. From the impacted resources, select 3 accounts.\n2) Remove the delegation.\n 10. Verify.'
|
||||||
|
});
|
||||||
|
expect(disclosed.recommendation).toBe('1. From the impacted resources, select [count] accounts.\n2) Remove the delegation.\n10. Verify.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps inline ordered lists but not a lone count followed by a sentence', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'maester', title: 'Synthetic',
|
||||||
|
description: 'Open the admin center. 1. Select Users. 2. Choose 3 accounts. 3. Fill the form. Found 1. Next steps follow.',
|
||||||
|
recommendation: 'Enable the rules. 1) Outbound rules. 2) Network Restrictions: apply them.'
|
||||||
|
});
|
||||||
|
expect(disclosed.description).toBe('Open the admin center. 1. Select Users. 2. Choose [count] accounts. 3. Fill the form. Found [count]. Next steps follow.');
|
||||||
|
expect(disclosed.recommendation).toBe('Enable the rules. 1) Outbound rules. 2) Network Restrictions: apply them.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles source quirks: repeated step numbers, markdown links showing their URL, code and sentence joins', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'maester', title: 'Navigate to the Microsoft 365 admin center',
|
||||||
|
description: '1. Navigate to Portal [https://portal.azure.com](https://portal.azure.com). 2. Search Entra. 3. Expand Manage. '
|
||||||
|
+ '3. On the Properties page continue. 4. Save. Open [blade](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/'
|
||||||
|
+ 'RecommendationDetails.ReactView/id/x). Run (Get-Policy).keyRestrictions.isEnforced and System.DateTime '
|
||||||
|
+ 'or Windows.Storage with ASP.NET. It affects any endpoint.The indicator stays. See aka.ms/mfa and '
|
||||||
|
+ 'test.mysmartlogon.com; contoso.It remains.'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe('Navigate to the Microsoft 365 admin center');
|
||||||
|
expect(disclosed.description).toBe('1. Navigate to Portal [https://portal.azure.com](https://portal.azure.com). 2. Search Entra. 3. Expand Manage. '
|
||||||
|
+ '3. On the Properties page continue. 4. Save. Open [blade](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/'
|
||||||
|
+ 'RecommendationDetails.ReactView/id/x). Run (Get-Policy).keyRestrictions.isEnforced and System.DateTime '
|
||||||
|
+ 'or Windows.Storage with ASP.NET. It affects any endpoint.The indicator stays. See aka.ms/mfa and '
|
||||||
|
+ '[domain]; [domain] remains.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps skipped step numbers, attribute flags and CISA section numbers', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'purple-knight', title: 'Synthetic',
|
||||||
|
description: '1. Create a group. 2. Open Entra. 3. Select Security. 3. Select Manage. 5. Under Admin, save. '
|
||||||
|
+ 'Accounts with adminCount=1 or the adminCount attribute set to 1 are protected; 4 accounts were found. '
|
||||||
|
+ 'See [CISA 4. Centralized Log Collection - MS.AAD.4.1v1].'
|
||||||
|
});
|
||||||
|
expect(disclosed.description).toBe('1. Create a group. 2. Open Entra. 3. Select Security. 3. Select Manage. 5. Under Admin, save. '
|
||||||
|
+ 'Accounts with adminCount=1 or the adminCount attribute set to 1 are protected; [count] accounts were found. '
|
||||||
|
+ 'See [CISA 4. Centralized Log Collection - MS.AAD.4.1v1].');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the space before a generic domain suffix such as .onmicrosoft.com', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'maester', sourceRuleId: 'MT.1074',
|
||||||
|
title: 'No more than 100 outbound mails per day are sent using the .onmicrosoft.com domain',
|
||||||
|
description: 'Spam from \'.onmicrosoft.com\' addresses , and contoso.onmicrosoft.com stays hidden .'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe('No more than [count] outbound mails per day are sent using the .onmicrosoft.com domain');
|
||||||
|
expect(disclosed.description).toBe('Spam from \'.onmicrosoft.com\' addresses, and [domain] stays hidden.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps recommended target values in recommendations only', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', title: 'Synthetic',
|
||||||
|
description: 'The value is set to 3 on this system.',
|
||||||
|
recommendation: 'RequireIntegrity and RequireMutualAuthentication should be set to 1. LDAPOwnerModify configured to 0.'
|
||||||
|
});
|
||||||
|
expect(disclosed.description).toBe('The value is set to [count] on this system.');
|
||||||
|
expect(disclosed.recommendation).toBe('RequireIntegrity and RequireMutualAuthentication should be set to 1. LDAPOwnerModify configured to 0.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps documented vendor values in recommendations only, never an observed count beside them', () => {
|
||||||
|
const guidance = 'Set the ms-DS-MachineAccountQuota attribute on the domain NC head to 0 to disable it. '
|
||||||
|
+ 'It is recommended to set it to 1. Set it to zero (0). The expected new value is 00000000010000000002000000011. '
|
||||||
|
+ 'Add the flag by adding the number 1048576. Assign 1 as the Value; other values include 2 for Audit mode and 6 for Warn mode. '
|
||||||
|
+ 'Use get-adobject -ldapfilter "(admincount=1)" and a 20+ length password. It can be done through 2 methods. '
|
||||||
|
+ 'Note: only 10 will be listed below.';
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', title: 'Synthetic',
|
||||||
|
description: guidance, recommendation: `${guidance} Currently 12 accounts are affected; set 4 of them to 0.`
|
||||||
|
});
|
||||||
|
expect(disclosed.recommendation).toBe(`${guidance} Currently [count] accounts are affected; set [count] of them to [count].`);
|
||||||
|
expect(disclosed.description).not.toMatch(/(?<![\w.])(?:0|1|2|6|10|20|1048576)(?![\w.])/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps public documentation links, file names, policy IDs and Graph permissions but redacts tenant references', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'maester', title: 'Synthetic',
|
||||||
|
recommendation: 'See [Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/overview) and '
|
||||||
|
+ 'https://github.com/cisagov/ScubaGear/blob/main/baselines/aad.md for MS.AAD.2.3 in the Entra admin center '
|
||||||
|
+ 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging?view=powershell-7 '
|
||||||
|
+ 'https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf '
|
||||||
|
+ '(entra.microsoft.com). Grant Directory.ReadWrite.All and Policy.ReadWrite.AuthenticationMethod. '
|
||||||
|
+ 'Check system32\\logfiles\\firewall\\pfirewall.log. '
|
||||||
|
+ 'Tenant links https://contoso.sharepoint.com/sites/it, https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2, '
|
||||||
|
+ 'https://portal.azure.com/#@contoso.onmicrosoft.com/resource, https://learn.microsoft.com/?tenant=contoso.de, '
|
||||||
|
+ 'https://portal.azure.com/#blade/subscriptions/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b/resourceGroups/rg-contoso-prod/overview, '
|
||||||
|
+ 'https://entra.microsoft.com/#view/Apps/objectId/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b/appId/x '
|
||||||
|
+ 'and domains contoso.onmicrosoft.com, mail.contoso.de stay hidden.'
|
||||||
|
});
|
||||||
|
expect(disclosed.recommendation).toBe('See [Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/overview) and '
|
||||||
|
+ 'https://github.com/cisagov/ScubaGear/blob/main/baselines/aad.md for MS.AAD.2.3 in the Entra admin center '
|
||||||
|
+ 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging?view=powershell-7 '
|
||||||
|
+ 'https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf '
|
||||||
|
+ '(entra.microsoft.com). Grant Directory.ReadWrite.All and Policy.ReadWrite.AuthenticationMethod. '
|
||||||
|
+ 'Check system32\\logfiles\\firewall\\pfirewall.log. '
|
||||||
|
+ 'Tenant links [url], [url], [url], [url], [url], [url] '
|
||||||
|
+ 'and domains [domain], [domain] stay hidden.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps vendor thresholds in recommendations but not in observed titles or descriptions', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', title: 'Password length is at least 7 characters for 15 accounts',
|
||||||
|
description: 'Observed: at least 7 characters on 15 accounts inactive for more than 55 days.',
|
||||||
|
recommendation: 'A strong password is at least 8 characters, rotated every 180 days, removed when older than 90 days, '
|
||||||
|
+ 'with 15 or more characters for service accounts and no more than 5 attempts; 3 accounts are affected.'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe('Password length is at least [count] characters for [count] accounts');
|
||||||
|
expect(disclosed.description).toBe('Observed: at least [count] characters on [count] accounts inactive for more than [duration].');
|
||||||
|
expect(disclosed.recommendation).toBe('A strong password is at least 8 characters, rotated every 180 days, removed when older than 90 days, '
|
||||||
|
+ 'with 15 or more characters for service accounts and no more than 5 attempts; [count] accounts are affected.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps public policy and registry paths and documented authorities instead of turning them into accounts', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '2.3.10.7',
|
||||||
|
recommendation: 'Set the following UI path to Enabled:\n'
|
||||||
|
+ 'Computer Configuration\\Policies\\Windows Settings\\Security Settings\\Local Policies\\Security Options\\Network access: Remotely accessible registry paths\n'
|
||||||
|
+ 'The value is stored in HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecurePipeServers\\Winreg\\AllowedExactPaths:Machine and '
|
||||||
|
+ 'System\\CurrentControlSet\\Control\\ProductOptions.\n'
|
||||||
|
+ 'Grant BUILTIN\\Administrators, NT SERVICE\\WdiServiceHost and RESTRICTED SERVICES\\PrintSpoolerService.\n'
|
||||||
|
+ 'Do not grant CONTOSO\\jane or CONTOSO\\svc-backup.'
|
||||||
|
});
|
||||||
|
expect(disclosed.recommendation).toBe('Set the following UI path to Enabled:\n'
|
||||||
|
+ 'Computer Configuration\\Policies\\Windows Settings\\Security Settings\\Local Policies\\Security Options\\Network access: Remotely accessible registry paths\n'
|
||||||
|
+ 'The value is stored in HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecurePipeServers\\Winreg\\AllowedExactPaths:Machine and '
|
||||||
|
+ 'System\\CurrentControlSet\\Control\\ProductOptions.\n'
|
||||||
|
+ 'Grant BUILTIN\\Administrators, NT SERVICE\\WdiServiceHost and RESTRICTED SERVICES\\PrintSpoolerService.\n'
|
||||||
|
+ 'Do not grant [account] or [account].');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Group Policy node paths, hard-wrapped registry paths, rule references and documented address ranges', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '2.3.10.9',
|
||||||
|
description: 'Use the built-in security template of Group Policy (e.g. Windows Settings\\Security Settings). '
|
||||||
|
+ 'It depends on Network security: LAN Manager Authentication Level (Rule 2.3.11.7).',
|
||||||
|
recommendation: 'System\\CurrentControlSet\\Control\\Terminal Server System\\CurrentControlSet\\Control\\Terminal\n'
|
||||||
|
+ 'Server\\UserConfig System\\CurrentControlSet\\Control\\ContentIndex.\nCONTOSO\\jane must not be listed.'
|
||||||
|
});
|
||||||
|
expect(disclosed.description).toBe('Use the built-in security template of Group Policy (e.g. Windows Settings\\Security Settings). '
|
||||||
|
+ 'It depends on Network security: LAN Manager Authentication Level (Rule 2.3.11.7).');
|
||||||
|
expect(disclosed.recommendation).toBe('System\\CurrentControlSet\\Control\\Terminal Server System\\CurrentControlSet\\Control\\Terminal\n'
|
||||||
|
+ 'Server\\UserConfig System\\CurrentControlSet\\Control\\ContentIndex.\n[account] must not be listed.');
|
||||||
|
|
||||||
|
const firewall = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'S-FirewallScript', title: 'Synthetic',
|
||||||
|
recommendation: 'Allow local network access: 10.0.0.0 to 10.255.255.255, 192.168.0.0 to 192.168.255.255; '
|
||||||
|
+ 'block 0.0.0.0 to 9.255.255.255 11.0.0.0 to 126.255.255.255 128.0.0.1 to 172.15.255.255 172.32.0.0 to 192.167.255.255 '
|
||||||
|
+ '192.169.0.0 to 255.255.255.255; host 10.20.30.40 and subnet 10.20.30.0 to 10.20.30.255 observed.'
|
||||||
|
});
|
||||||
|
expect(firewall.recommendation).toBe('Allow local network access: 10.0.0.0 to 10.255.255.255, 192.168.0.0 to 192.168.255.255; '
|
||||||
|
+ 'block 0.0.0.0 to 9.255.255.255 11.0.0.0 to 126.255.255.255 128.0.0.1 to 172.15.255.255 172.32.0.0 to 192.167.255.255 '
|
||||||
|
+ '192.169.0.0 to 255.255.255.255; '
|
||||||
|
+ 'host [ip] and subnet [ip] to [ip] observed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps multi-part CIS numbers in titles and redacts only standalone IPv4 addresses', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '18.9.20.1.1',
|
||||||
|
title: "18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'",
|
||||||
|
description: 'Rule 18.9.20.1 applies; the server 10.20.30.40 and 999.1.1.1 were observed.'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe("18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'");
|
||||||
|
expect(disclosed.description).toBe('Rule 18.9.20.1 applies; the server [ip] and 999.1.1.1 were observed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces compact durations such as 24h or 30d', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', title: 'Synthetic', description: 'Rotated 30d ago, checked every 24h, 5min, 2yrs.'
|
||||||
|
});
|
||||||
|
expect(disclosed.description).toBe('Rotated [duration] ago, checked every [duration], [duration], [duration].');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps registry-path GPO titles readable instead of treating them as DOMAIN\\account pairs', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
|
||||||
|
title: 'Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps documented Microsoft principals in the Soll value readable', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', title: 'Privilege Rights · SeServiceLogonRight',
|
||||||
|
policyBaselineValue: { kind: 'principal-set', principals: ['*S-1-5-113', '*S-1-5-83-0', 'S-1-5-9',
|
||||||
|
'NT SERVICE\\WdiServiceHost', 'NT VIRTUAL MACHINE\\Virtual Machines', 'Window Manager\\Window Manager Group',
|
||||||
|
'CONTOSO\\svc-backup', 'S-1-5-21-1-2-3-1105'] }
|
||||||
|
});
|
||||||
|
expect(disclosed.policyBaselineValue).toEqual({ kind: 'principal-set', principals: ['*S-1-5-113', '*S-1-5-83-0', 'S-1-5-9',
|
||||||
|
'NT SERVICE\\WdiServiceHost', 'NT VIRTUAL MACHINE\\Virtual Machines', 'Window Manager\\Window Manager Group',
|
||||||
|
'[DOMAIN_PRINCIPAL_1]', 'S-1-5-21-[DOMAIN]-1105'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps public benchmark values in CIS-CAT prose', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '1.1.4',
|
||||||
|
title: "(L1) Ensure 'Minimum password length' is set to '14 or more character(s)'",
|
||||||
|
recommendation: "Set 'Account lockout duration' to 15 or more minute(s)."
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disclosed.title).toBe("(L1) Ensure 'Minimum password length' is set to '14 or more character(s)'");
|
||||||
|
expect(disclosed.recommendation).toBe("Set 'Account lockout duration' to 15 or more minute(s).");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never discloses an unprotected number from non-benchmark scanner prose', () => {
|
||||||
|
const fingerprints = ['7 DC', '15 accounts', '55 days', '0 day(s) ago', '2026-08-04', '13:37', '1.015', '3,250'];
|
||||||
|
for (const tool of ['pingcastle', 'purple-knight', 'maester']) {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: tool, title: `Synthetic ${fingerprints.join(', ')}`,
|
||||||
|
description: fingerprints.join(' / '), recommendation: `Review ${fingerprints.join(' and ')}`
|
||||||
|
});
|
||||||
|
const prose = [disclosed.title, disclosed.description, disclosed.recommendation].join('\n');
|
||||||
|
expect(prose.replace(/\[(?:count|duration|date|time)\]/g, '')).not.toMatch(/\d/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discloses only the baseline family instead of the compared release', () => {
|
||||||
|
for (const [label, family] of [
|
||||||
|
['Microsoft Security Baseline Windows Server 2025 (2602)', 'Microsoft Security Baseline - Windows Server'],
|
||||||
|
['Windows 11 25H2 Security Baseline', 'Microsoft Security Baseline - Windows Client'],
|
||||||
|
['Microsoft 365 Apps for enterprise 2512', 'Microsoft Security Baseline - Microsoft 365 Apps'],
|
||||||
|
['Private customer template', 'Baseline recommendation']
|
||||||
|
]) {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', title: 'Synthetic setting',
|
||||||
|
policyBaselineLabel: label
|
||||||
|
});
|
||||||
|
expect(disclosed.policyBaselineLabel).toBe(family);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses identical baseline expectations so the compared release count is not disclosed', () => {
|
||||||
|
const variant = (id: string) => ({ settingId: id, baselineValues: [], comparator: {
|
||||||
|
version: 1 as const, comparator: { kind: 'equality' as const }, relation: 'different' as const,
|
||||||
|
actual: { kind: 'text' as const, value: '0' }, baseline: { kind: 'text' as const, value: '1' }, diagnosticCodes: []
|
||||||
|
} });
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', title: 'Synthetic setting',
|
||||||
|
policySourceCount: 4,
|
||||||
|
policyBaselineVariantAssessment: { version: 1, scope: 'backup-configuration', decision: 'all-fail',
|
||||||
|
actualValues: [], variants: [variant('server-2019'), variant('server-2022'), variant('server-2025')], diagnosticCodes: [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disclosed.policyBaselineVariantAssessment?.variants).toHaveLength(1);
|
||||||
|
expect(disclosed).not.toHaveProperty('policySourceCount');
|
||||||
|
expect(JSON.stringify(disclosed)).not.toMatch(/server-20\d\d/);
|
||||||
|
});
|
||||||
|
|
||||||
it('retains Purple Knight native rule identity and remediation text', () => {
|
it('retains Purple Knight native rule identity and remediation text', () => {
|
||||||
const disclosed = discloseResultDbProjection({
|
const disclosed = discloseResultDbProjection({
|
||||||
...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000055',
|
...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000055',
|
||||||
|
|
@ -112,7 +397,7 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => {
|
||||||
expect(disclosed.recommendation).not.toMatch(/CONTOSO|jane|10\.20\.30\.40/);
|
expect(disclosed.recommendation).not.toMatch(/CONTOSO|jane|10\.20\.30\.40/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps GPO Ist and Soll values while consistently redacting a non-public system SID', () => {
|
it('keeps only the GPO Soll value and withholds every customer value and principal', () => {
|
||||||
const systemSid = 'S-1-5-82-123456789-234567890-345678901-456789012-567890123';
|
const systemSid = 'S-1-5-82-123456789-234567890-345678901-456789012-567890123';
|
||||||
const disclosed = discloseResultDbProjection({
|
const disclosed = discloseResultDbProjection({
|
||||||
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
|
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
|
||||||
|
|
@ -130,14 +415,18 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(disclosed.policyActualValue).toMatchObject({ kind: 'text', value: '5',
|
expect(disclosed).not.toHaveProperty('policyActualValue');
|
||||||
principals: ['[SYSTEM_PRINCIPAL_1]', 'S-1-5-32-544'] });
|
|
||||||
expect(disclosed.policyBaselineValue).toMatchObject({ kind: 'text', value: '10' });
|
expect(disclosed.policyBaselineValue).toMatchObject({ kind: 'text', value: '10' });
|
||||||
expect(JSON.stringify(disclosed)).not.toContain(systemSid);
|
expect(disclosed.policyBaselineVariantAssessment).toMatchObject({ decision: 'all-fail', actualValues: [],
|
||||||
expect(JSON.stringify(disclosed).match(/\[SYSTEM_PRINCIPAL_1\]/g)).toHaveLength(3);
|
variants: [{ comparator: { relation: 'different', baseline: { kind: 'string-set', values: ['S-1-5-32-544'] } } }] });
|
||||||
|
expect(disclosed.policyBaselineVariantAssessment?.variants[0].comparator).not.toHaveProperty('actual');
|
||||||
|
const serialized = JSON.stringify(disclosed);
|
||||||
|
expect(serialized).not.toContain(systemSid);
|
||||||
|
expect(serialized).not.toContain('PRINCIPAL');
|
||||||
|
expect(serialized).not.toContain('"5"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the complete technical GPO deviation without customer or source identity', () => {
|
it('keeps the technical GPO setting, Soll value and relation without customer values or source identity', () => {
|
||||||
const disclosed = discloseResultDbProjection({
|
const disclosed = discloseResultDbProjection({
|
||||||
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
|
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
|
||||||
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
|
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
|
||||||
|
|
@ -177,28 +466,24 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => {
|
||||||
expect(disclosed).toMatchObject({
|
expect(disclosed).toMatchObject({
|
||||||
title: 'Restrict driver installation to administrators', sourceTool: 'GPOs',
|
title: 'Restrict driver installation to administrators', sourceTool: 'GPOs',
|
||||||
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
|
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
|
||||||
policyActualValue: {
|
|
||||||
kind: 'registry-policy', operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint',
|
|
||||||
valueName: 'RestrictDriverInstallationToAdministrators', typeCode: 4, data: { kind: 'uint32', value: 0 },
|
|
||||||
principals: ['S-1-5-18', 'S-1-5-32-544', 'BUILTIN\\Administrators', '[DOMAIN]\\Domain Admins',
|
|
||||||
'[DOMAIN_PRINCIPAL_1]', 'S-1-5-21-[DOMAIN]-512']
|
|
||||||
},
|
|
||||||
policyBaselineValue: { data: { kind: 'uint32', value: 1 } },
|
policyBaselineValue: { data: { kind: 'uint32', value: 1 } },
|
||||||
policyBaselineLabel: 'Microsoft Security Baseline Windows Server 2022 v1',
|
policyBaselineLabel: 'Microsoft Security Baseline - Windows Server',
|
||||||
|
policyComparisonRelation: 'different',
|
||||||
recommendation: 'Set RestrictDriverInstallationToAdministrators to 1.',
|
recommendation: 'Set RestrictDriverInstallationToAdministrators to 1.',
|
||||||
policyDiagnosticCodes: ['policy-safe-code'],
|
policyDiagnosticCodes: ['policy-safe-code'],
|
||||||
policyBaselineVariantAssessment: {
|
policyBaselineVariantAssessment: {
|
||||||
variants: [{ settingId: 'baseline-variant-1', comparator: {
|
actualValues: [],
|
||||||
actual: { kind: 'string-set', values: ['[DOMAIN_PRINCIPAL_2]'] },
|
variants: [{ settingId: 'baseline-expectation-1', comparator: {
|
||||||
baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] }
|
relation: 'different', baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] }
|
||||||
}}]
|
}}]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
expect(disclosed).not.toHaveProperty('policyActualValue');
|
||||||
expect(disclosed.policyIdentityKey).toBeUndefined();
|
expect(disclosed.policyIdentityKey).toBeUndefined();
|
||||||
expect(disclosed.policyBaselineId).toBeUndefined();
|
expect(disclosed.policyBaselineId).toBeUndefined();
|
||||||
const serialized = JSON.stringify(disclosed);
|
const serialized = JSON.stringify(disclosed);
|
||||||
for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY – Domain Controllers Security',
|
for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY', 'Domain Admins', 'PRINCIPAL', '2022',
|
||||||
'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', '123456789', '234567890', '345678901']) {
|
'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', 'named.user', '123456789', '234567890', '345678901']) {
|
||||||
expect(serialized).not.toContain(privateValue);
|
expect(serialized).not.toContain(privateValue);
|
||||||
}
|
}
|
||||||
expect(serialized).toContain('Backup comparison; effective endpoint state not verified');
|
expect(serialized).toContain('Backup comparison; effective endpoint state not verified');
|
||||||
|
|
@ -220,4 +505,63 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => {
|
||||||
scanRunId: 'scoped-scan'
|
scanRunId: 'scoped-scan'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the complete seven-level CIS number so sibling recommendations do not collide', () => {
|
||||||
|
const first = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '18.10.42.11.1.1.1' });
|
||||||
|
const second = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: 'xccdf_org.cisecurity.benchmarks_rule_18.10.42.11.1.1.2_L1_Ensure'
|
||||||
|
});
|
||||||
|
expect(first.sourceRuleId).toBe('18.10.42.11.1.1.1');
|
||||||
|
expect(second.sourceRuleId).toBe('18.10.42.11.1.1.2');
|
||||||
|
expect(discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '1.2.3.4.5.6.7.8.9.10' }).sourceRuleId)
|
||||||
|
.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mistake the ordinary word "limited" for an organization but still redacts legal names', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), title: 'Limited number of Global Admins are assigned.',
|
||||||
|
description: 'External sharing limited to existing guests.', recommendation: 'Contact Contoso Ltd or Fabrikam GmbH.'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe('Limited number of Global Admins are assigned.');
|
||||||
|
expect(disclosed.description).toBe('External sharing limited to existing guests.');
|
||||||
|
expect(disclosed.recommendation).not.toMatch(/Contoso|Fabrikam/);
|
||||||
|
expect(disclosed.recommendation).toContain('[organization]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps public versions, releases and step, option and page references in scanner prose', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'A-DCLdapsProtocolAdvanced',
|
||||||
|
title: 'At least one DC is using Tls1.0 or Tls1.1.',
|
||||||
|
description: 'NTLMv2 works starting Windows 95. See Microsoft Graph v1.0. If using a pre 2012-R2 schema, '
|
||||||
|
+ 'divide the value from step 2 by the value from step 3. Option 1 is described on Page 16. 4 DCs were observed.'
|
||||||
|
});
|
||||||
|
expect(disclosed.title).toBe('At least one DC is using Tls1.0 or Tls1.1.');
|
||||||
|
expect(disclosed.description).toBe('NTLMv2 works starting Windows 95. See Microsoft Graph v1.0. If using a pre 2012-R2 schema, '
|
||||||
|
+ 'divide the value from step 2 by the value from step 3. Option 1 is described on Page 16. [count] DCs were observed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps documented Microsoft GUIDs but redacts every other GUID', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({
|
||||||
|
...unsafeScanner(),
|
||||||
|
description: 'Add Azure DevOps (App ID: 499b84ac-1321-427f-aa17-267ca6975798) and block with ASR rule '
|
||||||
|
+ 'd3e037e1-3eb8-44c8-a917-57927947596d. Tenant 11111111-2222-4333-8444-555555555555 is affected.'
|
||||||
|
});
|
||||||
|
expect(disclosed.description).toBe('Add Azure DevOps (App ID: 499b84ac-1321-427f-aa17-267ca6975798) and block with ASR rule '
|
||||||
|
+ 'd3e037e1-3eb8-44c8-a917-57927947596d. Tenant [id] is affected.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withholds per-finding scanner scores', () => {
|
||||||
|
const disclosed = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000011', score: 73 });
|
||||||
|
expect(disclosed).not.toHaveProperty('score');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists withheld fields in the trace without revealing client review state', () => {
|
||||||
|
const reviewed = discloseResultDbProjectionWithTrace({ ...unsafeScanner(), reviewDisposition: 'accepted', reviewPriority: 'high' });
|
||||||
|
const unreviewed = discloseResultDbProjectionWithTrace({
|
||||||
|
...unsafeScanner(), reviewStatus: undefined, reviewOwner: undefined, reviewDueDate: undefined
|
||||||
|
});
|
||||||
|
expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields);
|
||||||
|
expect(JSON.stringify(reviewed.disclosureTrace)).not.toMatch(/review/i);
|
||||||
|
expect(reviewed.disclosureTrace?.withheldFields).toContain('affectedAsset');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AssessmentProject } from '../../src/domain/canonical-model';
|
||||||
|
import type { EnvironmentObservation } from '../../src/domain/environment-context';
|
||||||
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
|
import {
|
||||||
|
buildResultDbIdentityGuards,
|
||||||
|
collectResultDbIdentityLiterals,
|
||||||
|
ResultDbIdentityGuard
|
||||||
|
} from '../../src/domain/result-db-identity-guard';
|
||||||
|
import { discloseResultDbProjection, discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure';
|
||||||
|
|
||||||
|
const project: AssessmentProject = {
|
||||||
|
id: 'synthetic-project', name: 'Westeros Holding', createdAt: '2026-09-28T00:00:00Z', updatedAt: '2026-09-28T00:00:00Z',
|
||||||
|
attributes: { shortName: 'WSTR' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function observation(property: string, value: string, identity?: string): EnvironmentObservation {
|
||||||
|
return {
|
||||||
|
version: 1, id: `obs-${property}`, scanRunId: 'run', importFileId: 'file',
|
||||||
|
tool: { id: 'pingcastle', name: 'PingCastle' }, subject: { type: 'domain-information', identity },
|
||||||
|
property, label: property, originalValue: value, normalizedValue: value, valueType: 'string', state: 'observed',
|
||||||
|
source: { objectPath: 'synthetic' }, mappingVersion: 'synthetic', attributes: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanner(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResultProjection {
|
||||||
|
return {
|
||||||
|
resultProjectionId: 'scanner-finding:occ-1', sourceKind: 'scanner-finding', sourceResultId: 'occ-1',
|
||||||
|
projectId: project.id, assessmentId: 'timeslot-7f3a', collectionRunId: 'scan-run-7f3a', scanRunId: 'scan-run-7f3a', sourceTool: 'pingcastle',
|
||||||
|
sourceRuleId: 'A-DC-Spooler', title: 'Spooler is running on DC01', description: 'Host DC01 in sevenkingdoms.local exposes it.',
|
||||||
|
recommendation: 'Disable the spooler on WINTERFELL and ask Jon Snow.', affectedAsset: 'DC01',
|
||||||
|
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
||||||
|
technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true,
|
||||||
|
lifecycleState: 'unknown', mappingConfidence: 1, reviewOwner: 'Jon Snow', importedAt: '2026-09-28T00:00:00Z',
|
||||||
|
createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: [],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Result DB client identity guard', () => {
|
||||||
|
it('collects the identities a client names in its own data, without generic or public words', () => {
|
||||||
|
const literals = collectResultDbIdentityLiterals({
|
||||||
|
projects: [project],
|
||||||
|
environmentObservations: [observation('domain.fqdn', 'sevenkingdoms.local'), observation('host.name', 'WINTERFELL', 'WINTERFELL'),
|
||||||
|
observation('domain.functional_level', 'Windows Server 2016')],
|
||||||
|
affectedAssets: ['DC01', 'Domain Admins', 'krbtgt'],
|
||||||
|
reviewOwners: ['Jon Snow'],
|
||||||
|
principals: ['SEVENKINGDOMS\\arya.stark', 'BUILTIN\\Administrators', 'ned@sevenkingdoms.local']
|
||||||
|
});
|
||||||
|
// Case-insensitive: the NetBIOS name SEVENKINGDOMS and the domain label sevenkingdoms are one literal.
|
||||||
|
expect(literals.map(value => value.toLowerCase())).toEqual(expect.arrayContaining(['westeros holding', 'wstr',
|
||||||
|
'sevenkingdoms.local', 'sevenkingdoms', 'winterfell', 'dc01', 'jon snow', 'arya.stark', 'ned']));
|
||||||
|
expect(literals.map(value => value.toLowerCase())).not.toEqual(expect.arrayContaining(['domain admins', 'krbtgt', 'builtin',
|
||||||
|
'administrators', 'windows server 2016']));
|
||||||
|
expect(literals.indexOf('sevenkingdoms.local')).toBeLessThan(literals.indexOf('sevenkingdoms'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes names that no pattern recognizes, such as DC01 or a person, from every disclosed field', () => {
|
||||||
|
const guards = buildResultDbIdentityGuards({
|
||||||
|
projects: [project],
|
||||||
|
environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }],
|
||||||
|
internalResults: [scanner()]
|
||||||
|
});
|
||||||
|
const disclosed = discloseResultDbProjection(scanner(), guards.get(project.id));
|
||||||
|
const serialized = JSON.stringify(disclosed);
|
||||||
|
for (const identity of ['DC01', 'WINTERFELL', 'Jon Snow', 'sevenkingdoms']) expect(serialized).not.toContain(identity);
|
||||||
|
expect(disclosed.title).toBe('Spooler is running on [redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes only account forms from GPO values, never registry keys or paths', () => {
|
||||||
|
const guards = buildResultDbIdentityGuards({
|
||||||
|
projects: [project], environmentObservations: [],
|
||||||
|
internalResults: [scanner({ policyValues: [{ key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers', valueName: 'Machine\\Test',
|
||||||
|
principals: ['SEVENKINGDOMS\\arya.stark', 'HKLM\\Software', 'BUILTIN\\Administrators'] }] })]
|
||||||
|
});
|
||||||
|
const guard = guards.get(project.id)!;
|
||||||
|
expect(guard.text('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators'))
|
||||||
|
.toBe('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators');
|
||||||
|
expect(guard.text('Granted to arya.stark in SEVENKINGDOMS')).toBe('Granted to [redacted] in [redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withholds a whole field when an identity survives in another spelling', () => {
|
||||||
|
const guard = new ResultDbIdentityGuard(['sevenkingdoms.local']);
|
||||||
|
expect(guard.text('Forest seven-kingdoms . local is affected')).toBe('[withheld by identity guard]');
|
||||||
|
expect(guard.text('Forest sevenkingdoms.local is affected')).toBe('Forest [redacted] is affected');
|
||||||
|
expect(guard.text('Nothing identifying here')).toBe('Nothing identifying here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a content-free trace for every stored row', () => {
|
||||||
|
const guards = buildResultDbIdentityGuards({
|
||||||
|
projects: [project], environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }],
|
||||||
|
internalResults: [scanner()]
|
||||||
|
});
|
||||||
|
const row = discloseResultDbProjectionWithTrace(scanner({
|
||||||
|
description: 'Host DC01 has 15 accounts, see https://tenant.sharepoint.com/x.', reviewStatus: 'open'
|
||||||
|
}), guards.get(project.id));
|
||||||
|
expect(row.disclosureTrace).toMatchObject({ version: 1, guardWithheldFields: [] });
|
||||||
|
// DC01 is this finding's own affected asset (private-value); Jon Snow is caught by the client guard.
|
||||||
|
expect(row.disclosureTrace?.redactions).toEqual(expect.arrayContaining([
|
||||||
|
{ field: 'title', rule: 'private-value', count: 1 },
|
||||||
|
{ field: 'description', rule: 'count', count: 1 },
|
||||||
|
{ field: 'description', rule: 'url', count: 1 },
|
||||||
|
{ field: 'description', rule: 'private-value', count: 1 },
|
||||||
|
{ field: 'recommendation', rule: 'private-value', count: 1 },
|
||||||
|
{ field: 'recommendation', rule: 'identity-guard', count: 1 }
|
||||||
|
]));
|
||||||
|
expect(row.disclosureTrace?.withheldFields).toContain('affectedAsset');
|
||||||
|
// Client-owned review state is withheld without a trace entry, so its presence is not revealed.
|
||||||
|
expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/review/i);
|
||||||
|
expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/DC01|WINTERFELL|Jon Snow|15|sharepoint/);
|
||||||
|
expect(row.recommendation).toBe('Disable the spooler on [redacted] and ask [redacted].');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: disclosing a stored row again changes nothing, so UI, API and export show the same content', () => {
|
||||||
|
const guards = buildResultDbIdentityGuards({ projects: [project], environmentObservations: [], internalResults: [scanner()] });
|
||||||
|
const fixtures = [
|
||||||
|
scanner(),
|
||||||
|
scanner({ sourceTool: 'maester', sourceRuleId: 'MT.1074', title: 'No more than 100 mails via the .onmicrosoft.com domain',
|
||||||
|
description: '1. Open [Entra](https://entra.microsoft.com). 2. Select Users. 3. Save. Found 4 accounts.',
|
||||||
|
recommendation: 'Set at least 8 characters; see https://learn.microsoft.com/en-us/entra/ and Directory.ReadWrite.All.' }),
|
||||||
|
scanner({ sourceTool: 'cis-cat', sourceRuleId: '18.9.20.1.1',
|
||||||
|
title: "18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'",
|
||||||
|
recommendation: 'Computer Configuration\\Policies\\Administrative Templates\\System\\Internet Communication Management' })
|
||||||
|
];
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const stored = discloseResultDbProjectionWithTrace(fixture, guards.get(project.id));
|
||||||
|
expect(discloseResultDbProjection(stored)).toEqual(stored);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,16 +28,19 @@ function observation(overrides: Partial<EnvironmentObservation> = {}): Environme
|
||||||
|
|
||||||
describe('Result DB supplemental disclosure boundary', () => {
|
describe('Result DB supplemental disclosure boundary', () => {
|
||||||
it('rebuilds an approved value from controlled metadata without carrying source identity or raw context', () => {
|
it('rebuilds an approved value from controlled metadata without carrying source identity or raw context', () => {
|
||||||
const disclosed = discloseSupplementalObservation(observation());
|
const disclosed = discloseSupplementalObservation(observation({
|
||||||
|
property: 'domain.recycle_bin_enabled', normalizedValue: true, originalValue: 'Enabled for Private Customer',
|
||||||
|
valueType: 'boolean'
|
||||||
|
}));
|
||||||
|
|
||||||
expect(disclosed).toEqual({
|
expect(disclosed).toEqual({
|
||||||
policyVersion: RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION,
|
policyVersion: RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION,
|
||||||
disclosureClass: 'technical-metadata',
|
disclosureClass: 'configuration-state',
|
||||||
property: 'domain.functional_level',
|
property: 'domain.recycle_bin_enabled',
|
||||||
title: 'Domain functional level',
|
title: 'AD Recycle Bin enabled',
|
||||||
subjectType: 'active-directory-environment',
|
subjectType: 'active-directory-environment',
|
||||||
value: 2016,
|
value: true,
|
||||||
valueType: 'number',
|
valueType: 'boolean',
|
||||||
state: 'observed'
|
state: 'observed'
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(disclosed)).not.toMatch(/Private Customer|private\.synthetic|customer\/path/i);
|
expect(JSON.stringify(disclosed)).not.toMatch(/Private Customer|private\.synthetic|customer\/path/i);
|
||||||
|
|
@ -58,10 +61,15 @@ describe('Result DB supplemental disclosure boundary', () => {
|
||||||
}))).toBeUndefined();
|
}))).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows bounded aggregate and boolean configuration values only', () => {
|
it('withholds counts, scores, levels and versions that fingerprint the environment', () => {
|
||||||
expect(discloseSupplementalObservation(observation({
|
for (const [property, value] of [['risk.domain', 42], ['domain.controller_count', 1], ['domain.functional_level', 2016],
|
||||||
property: 'risk.domain', normalizedValue: 42, originalValue: 'Private score 42'
|
['forest.schema_version', 88], ['kerberos.krbtgt.version', 3], ['report.engine_version', '3.3.0.0'],
|
||||||
}))).toMatchObject({ value: 42, disclosureClass: 'aggregate' });
|
['logging.wef.configuration_count', 0]] as const) {
|
||||||
|
expect(discloseSupplementalObservation(observation({ property, normalizedValue: value }))).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows bounded boolean configuration values only', () => {
|
||||||
expect(discloseSupplementalObservation(observation({
|
expect(discloseSupplementalObservation(observation({
|
||||||
property: 'domain.recycle_bin_enabled', normalizedValue: true, originalValue: 'Private enabled', valueType: 'boolean'
|
property: 'domain.recycle_bin_enabled', normalizedValue: true, originalValue: 'Private enabled', valueType: 'boolean'
|
||||||
}))).toMatchObject({ value: true, disclosureClass: 'configuration-state' });
|
}))).toMatchObject({ value: true, disclosureClass: 'configuration-state' });
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
import {
|
import {
|
||||||
buildUnifiedResultJsonl,
|
buildUnifiedResultJsonl,
|
||||||
queryAllUnifiedResults,
|
queryAllUnifiedResults,
|
||||||
|
RESULT_DB_EXPORT_HANDLING,
|
||||||
RESULT_DB_JSONL_FORMAT,
|
RESULT_DB_JSONL_FORMAT,
|
||||||
RESULT_DB_JSONL_SCHEMA_VERSION,
|
RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||||
unifiedResultJsonlFileName
|
unifiedResultJsonlFileName
|
||||||
|
|
@ -48,8 +49,9 @@ describe('filtered Result DB JSONL export', () => {
|
||||||
recordType: 'manifest',
|
recordType: 'manifest',
|
||||||
format: RESULT_DB_JSONL_FORMAT,
|
format: RESULT_DB_JSONL_FORMAT,
|
||||||
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||||
disclosurePolicyVersion: '2.5.3',
|
disclosurePolicyVersion: '3.4.0',
|
||||||
generatedAt: '2026-09-03T12:34:56.789Z',
|
handling: RESULT_DB_EXPORT_HANDLING,
|
||||||
|
generatedOn: '2026-09-03',
|
||||||
resultCount: 2,
|
resultCount: 2,
|
||||||
scope: { projectScoped: true, assessmentScopeCount: 0, scanScopeCount: 1, evaluationScoped: false },
|
scope: { projectScoped: true, assessmentScopeCount: 0, scanScopeCount: 1, evaluationScoped: false },
|
||||||
filters: { deviation: true, severity: { active: true, selectionCount: 2 } }
|
filters: { deviation: true, severity: { active: true, selectionCount: 2 } }
|
||||||
|
|
@ -60,7 +62,8 @@ describe('filtered Result DB JSONL export', () => {
|
||||||
expect(text).not.toMatch(/project-1|assessment-1|scan-1|source-result-1|synthetic/i);
|
expect(text).not.toMatch(/project-1|assessment-1|scan-1|source-result-1|synthetic/i);
|
||||||
expect(text.endsWith('\n')).toBe(true);
|
expect(text.endsWith('\n')).toBe(true);
|
||||||
expect(unifiedResultJsonlFileName('2026-09-03T12:34:56.789Z'))
|
expect(unifiedResultJsonlFileName('2026-09-03T12:34:56.789Z'))
|
||||||
.toBe('assessor-result-db-filtered-2026-09-03T12-34-56Z.jsonl');
|
.toBe('assessor-result-db-filtered-2026-09-03.jsonl');
|
||||||
|
expect(text).not.toContain('12:34');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads every filtered page with the same scope and rejects a changing result set', async () => {
|
it('loads every filtered page with the same scope and rejects a changing result set', async () => {
|
||||||
|
|
@ -139,7 +142,29 @@ describe('filtered Result DB JSONL export', () => {
|
||||||
expect(text).not.toMatch(/person@|Private Company|Jane Doe|private-company|private-run|private\/company\/path/i);
|
expect(text).not.toMatch(/person@|Private Company|Jane Doe|private-company|private-run|private\/company\/path/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports the same actionable GPO comparison shown in Result DB', () => {
|
it('never exports client-owned review, priority or risk-acceptance state', () => {
|
||||||
|
const reviewed = {
|
||||||
|
...result('synthetic-a'), sourceTool: 'purple-knight', sourceRuleId: 'SI000055',
|
||||||
|
reviewStatus: 'open', reviewDisposition: 'accepted', reviewPriority: 'high',
|
||||||
|
reviewOwner: 'Synthetic Owner', reviewDueDate: '2026-10-01'
|
||||||
|
} satisfies UnifiedResultProjection;
|
||||||
|
const text = buildUnifiedResultJsonl({ reviewDisposition: ['accepted'] }, [reviewed], '2026-09-28T10:00:00.000Z');
|
||||||
|
const [manifest, line] = text.trimEnd().split('\n').map(item => JSON.parse(item));
|
||||||
|
|
||||||
|
expect(manifest.handling).toEqual({ classification: 'confidential-security-posture', notice: expect.any(String) });
|
||||||
|
expect(manifest.filters).toEqual({});
|
||||||
|
expect(line.result.sourceRuleId).toBe('SI000055');
|
||||||
|
expect(text).not.toMatch(/review|priority|accepted|Synthetic Owner|2026-10-01/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops fields that the disclosure policy has not classified', () => {
|
||||||
|
const extended = { ...result('extended'), futureCustomerField: 'Synthetic Customer' } as UnifiedResultProjection;
|
||||||
|
const text = buildUnifiedResultJsonl({}, [extended], '2026-09-28T10:00:00.000Z');
|
||||||
|
|
||||||
|
expect(text).not.toMatch(/futureCustomerField|Synthetic Customer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports the GPO setting, Soll value and baseline family without customer values', () => {
|
||||||
const policy = {
|
const policy = {
|
||||||
...result('policy-result'), sourceKind: 'policy-evaluation' as const, sourceTool: 'GPOs',
|
...result('policy-result'), sourceKind: 'policy-evaluation' as const, sourceTool: 'GPOs',
|
||||||
sourceRuleId: 'registry:machine:software\\policies\\synthetic:mode', title: 'Synthetic policy mode',
|
sourceRuleId: 'registry:machine:software\\policies\\synthetic:mode', title: 'Synthetic policy mode',
|
||||||
|
|
@ -155,16 +180,14 @@ describe('filtered Result DB JSONL export', () => {
|
||||||
const text = buildUnifiedResultJsonl({}, [policy], '2026-09-23T10:00:00.000Z');
|
const text = buildUnifiedResultJsonl({}, [policy], '2026-09-23T10:00:00.000Z');
|
||||||
expect(text).toContain('Synthetic policy mode');
|
expect(text).toContain('Synthetic policy mode');
|
||||||
expect(text).toContain('registry:machine:software');
|
expect(text).toContain('registry:machine:software');
|
||||||
expect(text).toContain('policyActualValue');
|
|
||||||
expect(text).toContain('policyBaselineValue');
|
expect(text).toContain('policyBaselineValue');
|
||||||
expect(text).toContain('BUILTIN\\\\Administrators');
|
expect(text).toContain('"policyBaselineLabel":"Microsoft Security Baseline - Windows Server"');
|
||||||
expect(text).toContain('[DOMAIN]\\\\Domain Admins');
|
expect(text).not.toContain('policyActualValue');
|
||||||
expect(text).toContain('[DOMAIN_PRINCIPAL_1]');
|
expect(text).not.toMatch(/Administrators|Domain Admins|PRINCIPAL|S-1-5-21|2022/);
|
||||||
expect(text).toContain('S-1-5-21-[DOMAIN]-512');
|
|
||||||
expect(text).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|private-policy-hash/);
|
expect(text).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|private-policy-hash/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports numeric GPO values and public CIS guidance without an application-pool SID', () => {
|
it('exports the GPO Soll value and public CIS guidance without the customer value or a system SID', () => {
|
||||||
const systemSid = 'S-1-5-82-123456789-234567890-345678901-456789012-567890123';
|
const systemSid = 'S-1-5-82-123456789-234567890-345678901-456789012-567890123';
|
||||||
const cis = { ...result('cis'), sourceTool: 'cis-cat', sourceRuleId: '18.10.36.1',
|
const cis = { ...result('cis'), sourceTool: 'cis-cat', sourceRuleId: '18.10.36.1',
|
||||||
title: "18.10.36.1 Ensure 'Turn off location' is set to 'Enabled'",
|
title: "18.10.36.1 Ensure 'Turn off location' is set to 'Enabled'",
|
||||||
|
|
@ -179,9 +202,9 @@ describe('filtered Result DB JSONL export', () => {
|
||||||
const exported = buildUnifiedResultJsonl({}, [cis, gpo], '2026-09-25T10:00:00.000Z');
|
const exported = buildUnifiedResultJsonl({}, [cis, gpo], '2026-09-25T10:00:00.000Z');
|
||||||
expect(exported).toContain('18.10.36.1');
|
expect(exported).toContain('18.10.36.1');
|
||||||
expect(exported).toContain('Computer Configuration\\\\Administrative Templates');
|
expect(exported).toContain('Computer Configuration\\\\Administrative Templates');
|
||||||
expect(exported).toContain('"value":"5"');
|
|
||||||
expect(exported).toContain('"value":"10"');
|
expect(exported).toContain('"value":"10"');
|
||||||
expect(exported).toContain('[SYSTEM_PRINCIPAL_1]');
|
expect(exported).not.toContain('"value":"5"');
|
||||||
|
expect(exported).not.toContain('PRINCIPAL');
|
||||||
expect(exported).not.toContain(systemSid);
|
expect(exported).not.toContain(systemSid);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ describe('client info environment detail', () => {
|
||||||
expect(detail.restricted).toEqual(['Honey Pot', 'Microsoft Entra Connect settings']);
|
expect(detail.restricted).toEqual(['Honey Pot', 'Microsoft Entra Connect settings']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows double-size environment tiles with summary facts and keeps complete evidence in Results', () => {
|
it('keeps all six square overview tiles on one row and complete evidence in Results', () => {
|
||||||
expect(component).toContain('className="client-environment-grid" aria-label="Environment overview"');
|
expect(component).toContain('className="client-environment-grid" aria-label="Environment overview"');
|
||||||
expect(component).not.toContain('className="client-environment-detail"');
|
expect(component).not.toContain('className="client-environment-detail"');
|
||||||
expect(component).not.toContain('environmentDetailRef');
|
expect(component).not.toContain('environmentDetailRef');
|
||||||
|
|
@ -137,8 +137,15 @@ describe('client info environment detail', () => {
|
||||||
expect(component).not.toContain('title="Source metrics" items={environmentDetail.toolContext}');
|
expect(component).not.toContain('title="Source metrics" items={environmentDetail.toolContext}');
|
||||||
expect(component).toContain('icon={<DashboardGlyph name="computer"/>}');
|
expect(component).toContain('icon={<DashboardGlyph name="computer"/>}');
|
||||||
expect(component).not.toContain('className="client-source-overview"');
|
expect(component).not.toContain('className="client-source-overview"');
|
||||||
expect(styles).toMatch(/\.client-environment-grid\s*\{[^}]*grid-auto-flow:\s*column;[^}]*grid-auto-columns:\s*min\(var\(--tile-double-compact-width\), 100%\);[^}]*overflow-x:\s*auto;/s);
|
const overviewLayout = styles.match(/\.client-environment-grid\s*\{([^}]*)\}/)?.[1] ?? '';
|
||||||
expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*min-height:\s*var\(--tile-double-compact-width\);[^}]*background:\s*var\(--tile-surface\);/s);
|
expect(overviewLayout).toMatch(/display:\s*grid;\s*grid-template-columns:\s*repeat\(6, minmax\(var\(--tile-environment-min-width\), 1fr\)\);/);
|
||||||
|
expect(overviewLayout).toMatch(/overflow-x:\s*auto;/);
|
||||||
|
expect(overviewLayout).not.toMatch(/flex-wrap:\s*wrap|grid-template-rows:/);
|
||||||
|
expect(component).toContain('const tileItems = (overviewItems ?? items).slice(0, 4);');
|
||||||
|
expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*aspect-ratio:\s*1;[^}]*overflow:\s*hidden;/s);
|
||||||
|
expect(styles).toMatch(/@container environment-tile \(max-width: 17rem\)\s*\{\s*\.client-env-facts > div:nth-child\(n \+ 3\)\s*\{\s*display:\s*none;/s);
|
||||||
|
expect(styles).not.toMatch(/\.client-environment-grid > section > \.client-env-facts dd\s*\{[^}]*overflow-wrap:\s*normal;/s);
|
||||||
|
expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*background:\s*var\(--tile-surface\);/s);
|
||||||
expect(styles).toMatch(/\.client-environment-sources li > div,[\s\S]*?\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/s);
|
expect(styles).toMatch(/\.client-environment-sources li > div,[\s\S]*?\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/s);
|
||||||
expect(styles).toMatch(/\.client-env-group-title > \.client-glyph\s*\{[^}]*width:\s*var\(--space-5\);[^}]*color:\s*var\(--client-info-muted\);/s);
|
expect(styles).toMatch(/\.client-env-group-title > \.client-glyph\s*\{[^}]*width:\s*var\(--space-5\);[^}]*color:\s*var\(--client-info-muted\);/s);
|
||||||
expect(styles).toMatch(/\.client-environment-grid \.client-env-group-title,[\s\S]*?\.client-command-center \.client-environment-grid > \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
expect(styles).toMatch(/\.client-environment-grid \.client-env-group-title,[\s\S]*?\.client-command-center \.client-environment-grid > \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
|
|
|
||||||
|
|
@ -524,7 +524,8 @@ describe('selectFindingFields', () => {
|
||||||
sheets: [], sections: resultSectionsFor(attachmentRecord, 'maester')
|
sheets: [], sections: resultSectionsFor(attachmentRecord, 'maester')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(reader).not.toContain('Your tenant does not cover all recommended');
|
// User decision 2026-09-28: the statement is the problem; the table stays in Results only.
|
||||||
|
expect(reader).toContain('<dt>Result</dt><dd><p>Your tenant does not cover all recommended file attachment extensions:</p></dd>');
|
||||||
expect(reader).not.toContain('<h4>Test result</h4>');
|
expect(reader).not.toContain('<h4>Test result</h4>');
|
||||||
expect(reader).not.toContain('Extension name');
|
expect(reader).not.toContain('Extension name');
|
||||||
expect(reader).toContain('CIS.M365.2.1.11');
|
expect(reader).toContain('CIS.M365.2.1.11');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import { deriveUnifiedResultFacets, queryUnifiedResultItems, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
|
import { ResultRow, ResultsBrowser } from '../../src/features/results-browser/ResultsBrowser';
|
||||||
|
import { discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure';
|
||||||
|
import { ResultDbIdentityGuard } from '../../src/domain/result-db-identity-guard';
|
||||||
|
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
|
||||||
|
|
||||||
|
function reviewedFinding(id: string): UnifiedResultProjection {
|
||||||
|
return {
|
||||||
|
resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceKind: 'scanner-finding',
|
||||||
|
sourceTool: 'purple-knight', sourceRuleId: 'SI000055', projectId: 'synthetic', assessmentId: 'slot',
|
||||||
|
collectionRunId: 'collection', scanRunId: 'scan', title: `Synthetic delegation finding ${id}`,
|
||||||
|
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
||||||
|
technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true,
|
||||||
|
lifecycleState: 'unknown', mappingConfidence: 1,
|
||||||
|
reviewStatus: 'open', reviewDisposition: 'accepted', reviewPriority: 'critical',
|
||||||
|
reviewOwner: 'Synthetic Owner', reviewDueDate: '2026-10-01',
|
||||||
|
importedAt: '2026-09-28T00:00:00Z', createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
describe('Result DB export disclosure', () => {
|
||||||
|
it('shows no review column or review filter and exports JSONL without review state or environment fingerprints', async () => {
|
||||||
|
const items = [reviewedFinding('a'), {
|
||||||
|
...reviewedFinding('b'), sourceTool: 'pingcastle', sourceRuleId: 'A-NotEnoughDC',
|
||||||
|
title: 'The number of DCs is too small to provide redundancy: 1 DC',
|
||||||
|
description: 'Last backup 55 days ago; 15 accounts never expire.'
|
||||||
|
}];
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets')
|
||||||
|
.mockImplementation(async query => deriveUnifiedResultFacets(items, query));
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults')
|
||||||
|
.mockImplementation(async query => queryUnifiedResultItems(items, query));
|
||||||
|
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||||||
|
expect(container.querySelectorAll('.results-browser-title')).toHaveLength(2);
|
||||||
|
const headings = [...container.querySelectorAll('.results-browser-head span')].map(item => item.textContent);
|
||||||
|
expect(headings).not.toContain('Review');
|
||||||
|
expect(container.querySelector('[data-label="Review"]')).toBeNull();
|
||||||
|
const filterHeadings = [...container.querySelectorAll('section.results-filter-group h3')].map(item => item.textContent);
|
||||||
|
expect(filterHeadings.some(heading => /review/i.test(heading ?? ''))).toBe(false);
|
||||||
|
|
||||||
|
const exportButton = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||||||
|
.find(button => button.textContent === 'Export JSONL')!;
|
||||||
|
await act(async () => exportButton.click());
|
||||||
|
|
||||||
|
expect(download).toHaveBeenCalledTimes(1);
|
||||||
|
const text = download.mock.calls[0][0].text;
|
||||||
|
const [manifest, ...results] = text.trimEnd().split('\n').map(line => JSON.parse(line));
|
||||||
|
expect(manifest.handling.classification).toBe('confidential-security-posture');
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.map(line => line.result.sourceRuleId).sort()).toEqual(['A-NotEnoughDC', 'SI000055']);
|
||||||
|
expect(text).not.toMatch(/reviewStatus|reviewDisposition|reviewPriority|reviewOwner|reviewDueDate|Synthetic Owner|accepted/);
|
||||||
|
const dc = results.find(line => line.result.sourceRuleId === 'A-NotEnoughDC')!.result;
|
||||||
|
expect(dc.title).toBe('The number of DCs is too small to provide redundancy: [count] DC');
|
||||||
|
expect(dc.description).toBe('Last backup [duration] ago; [count] accounts never expire.');
|
||||||
|
expect(manifest).not.toHaveProperty('generatedAt');
|
||||||
|
expect(download.mock.calls[0][0].fileName).toMatch(/^assessor-result-db-filtered-\d{4}-\d{2}-\d{2}\.jsonl$/);
|
||||||
|
} finally { await act(async () => root.unmount()); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reveals that a stored row had client review state, neither in the row detail nor in the JSONL export', async () => {
|
||||||
|
// The backend serves materialized rows that carry their trace, so this is the real export input.
|
||||||
|
const reviewed = discloseResultDbProjectionWithTrace({ ...reviewedFinding('first'), score: 27 });
|
||||||
|
const unreviewed = discloseResultDbProjectionWithTrace({
|
||||||
|
...reviewedFinding('second'), reviewStatus: undefined, reviewDisposition: undefined,
|
||||||
|
reviewPriority: undefined, reviewOwner: undefined, reviewDueDate: undefined, score: 96
|
||||||
|
});
|
||||||
|
const items = [reviewed, unreviewed];
|
||||||
|
expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields);
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets')
|
||||||
|
.mockImplementation(async query => deriveUnifiedResultFacets(items, query));
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults')
|
||||||
|
.mockImplementation(async query => queryUnifiedResultItems(items, query));
|
||||||
|
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<ResultRow item={reviewed} expanded={true} onToggle={() => undefined}
|
||||||
|
workRecordIds={[]} onOpenInWork={() => undefined}/>));
|
||||||
|
expect(container.textContent).not.toMatch(/review|Synthetic Owner/i);
|
||||||
|
|
||||||
|
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||||||
|
const exportButton = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||||||
|
.find(button => button.textContent === 'Export JSONL')!;
|
||||||
|
await act(async () => exportButton.click());
|
||||||
|
const text = download.mock.calls[0][0].text;
|
||||||
|
const results = text.trimEnd().split('\n').slice(1).map(line => JSON.parse(line).result);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.every(result => result.disclosureTrace)).toBe(true);
|
||||||
|
expect(text).not.toMatch(/review|Synthetic Owner/i);
|
||||||
|
expect(results.some(result => 'score' in result)).toBe(false);
|
||||||
|
} finally { await act(async () => root.unmount()); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the stored trace of a row and opens its original in Work without showing it in the Result DB', async () => {
|
||||||
|
const stored = discloseResultDbProjectionWithTrace({
|
||||||
|
...reviewedFinding('traced'), affectedAsset: 'SYNTHETIC-HOST-9', description: 'Host SRVDC01 answers for 3 hosts.'
|
||||||
|
}, new ResultDbIdentityGuard(['SRVDC01']));
|
||||||
|
const opened = vi.fn();
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<ResultRow item={stored} expanded={true} onToggle={() => undefined}
|
||||||
|
workRecordIds={['work-record-1']} onOpenInWork={opened}/>));
|
||||||
|
const detail = container.querySelector('.results-disclosure-trace')?.textContent ?? '';
|
||||||
|
expect(detail).toContain('Checked content');
|
||||||
|
expect(detail).toContain('Redacted in Description: count × 1, identity-guard × 1');
|
||||||
|
expect(detail).toContain('Withheld fields:');
|
||||||
|
expect(container.textContent).not.toContain('SRVDC01');
|
||||||
|
const button = [...container.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent === 'Open original in Work')!;
|
||||||
|
await act(async () => button.click());
|
||||||
|
expect(opened).toHaveBeenCalledWith(['work-record-1']);
|
||||||
|
|
||||||
|
await act(async () => root.render(<ResultRow item={stored} expanded={true} onToggle={() => undefined} workRecordIds={[]} onOpenInWork={opened}/>));
|
||||||
|
expect(container.textContent).toContain('The original is not part of the loaded Work scope.');
|
||||||
|
} finally { await act(async () => root.unmount()); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,7 +22,7 @@ function result(id: string, classification: NonNullable<UnifiedResultProjection[
|
||||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
describe('Result DB policy filter consistency', () => {
|
describe('Result DB policy filter consistency', () => {
|
||||||
it('shows the concrete GPO setting and readable configured and expected values when expanded', async () => {
|
it('shows the concrete GPO setting, expected value and relation without the customer value when expanded', async () => {
|
||||||
const item: UnifiedResultProjection = {
|
const item: UnifiedResultProjection = {
|
||||||
...result('gpo-readable', 'misconfigured'),
|
...result('gpo-readable', 'misconfigured'),
|
||||||
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
|
sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators',
|
||||||
|
|
@ -47,17 +47,16 @@ describe('Result DB policy filter consistency', () => {
|
||||||
expect(container.querySelector('[data-label="Source Rule ID"]')?.textContent)
|
expect(container.querySelector('[data-label="Source Rule ID"]')?.textContent)
|
||||||
.toBe('registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
|
.toBe('registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
|
||||||
const detail = container.querySelector('.results-browser-row-detail')?.textContent ?? '';
|
const detail = container.querySelector('.results-browser-row-detail')?.textContent ?? '';
|
||||||
expect(detail).toContain('Configured customer value');
|
// 2026-09-28: the Result DB shows the Soll value and relation only; the customer value stays in Work.
|
||||||
expect(detail).toContain('Data: 0');
|
expect(detail).not.toContain('Configured customer value');
|
||||||
|
expect(detail).not.toContain('Data: 0');
|
||||||
expect(detail).toContain('Expected baseline value');
|
expect(detail).toContain('Expected baseline value');
|
||||||
expect(detail).toContain('Data: 1');
|
expect(detail).toContain('Data: 1');
|
||||||
|
expect(detail).toContain('Comparison: different');
|
||||||
expect(detail).toContain('Technical setting ID: registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
|
expect(detail).toContain('Technical setting ID: registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
|
||||||
expect(detail).toContain('Microsoft Security Baseline Windows Server 2022 v1');
|
expect(detail).toContain('Baseline: Microsoft Security Baseline - Windows Server');
|
||||||
expect(detail).toContain('BUILTIN\\Administrators');
|
|
||||||
expect(detail).toContain('[DOMAIN]\\Domain Admins');
|
|
||||||
expect(detail).toContain('[DOMAIN_PRINCIPAL_1]');
|
|
||||||
expect(detail).toContain('S-1-5-21-[DOMAIN]-512');
|
|
||||||
expect(detail).toContain('Backup comparison; effective endpoint state not verified');
|
expect(detail).toContain('Backup comparison; effective endpoint state not verified');
|
||||||
|
expect(detail).not.toMatch(/Administrators|Domain Admins|PRINCIPAL|S-1-5-21|2022/);
|
||||||
expect(detail).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|99999999|private-policy-hash|private-baseline-id/);
|
expect(detail).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|99999999|private-policy-hash|private-baseline-id/);
|
||||||
expect(detail).not.toContain('Privacy-safe Group Policy comparison');
|
expect(detail).not.toContain('Privacy-safe Group Policy comparison');
|
||||||
} finally { await act(async () => root.unmount()); }
|
} finally { await act(async () => root.unmount()); }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||||
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
|
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
||||||
|
|
||||||
|
function dataset(records: Array<{ id: string; findingOccurrenceId?: string }>): ImportedDataset {
|
||||||
|
return { sheets: [{ analysis: { records } }] } as unknown as ImportedDataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Result DB original lookup in Work', () => {
|
||||||
|
it('finds the Work record of a scanner row by its finding occurrence', () => {
|
||||||
|
const row = { sourceKind: 'scanner-finding', sourceResultId: 'occurrence-7' } as UnifiedResultProjection;
|
||||||
|
expect(workRecordIdsForResult(dataset([{ id: 'record-a', findingOccurrenceId: 'occurrence-7' },
|
||||||
|
{ id: 'record-b', findingOccurrenceId: 'occurrence-8' }]), row)).toEqual(['record-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the Work setting of a GPO comparison by scope and technical setting identity', () => {
|
||||||
|
const key = (scope: string, identity: string) =>
|
||||||
|
`gpo-work-setting:${encodeURIComponent(JSON.stringify(['project', 'aggregation', scope, identity]))}`;
|
||||||
|
const row = { sourceKind: 'policy-evaluation', policyScope: 'machine', sourceRuleId: 'registry:machine:software\\x:y' } as unknown as UnifiedResultProjection;
|
||||||
|
expect(workRecordIdsForResult(dataset([{ id: key('machine', 'registry:machine:software\\x:y') },
|
||||||
|
{ id: key('user', 'registry:machine:software\\x:y') }, { id: 'gpo-work-setting:%7Bbroken' }]), row))
|
||||||
|
.toEqual([key('machine', 'registry:machine:software\\x:y')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing without a loaded Work dataset or for environment rows', () => {
|
||||||
|
expect(workRecordIdsForResult(null, { sourceKind: 'scanner-finding', sourceResultId: 'x' } as UnifiedResultProjection)).toEqual([]);
|
||||||
|
expect(workRecordIdsForResult(dataset([{ id: 'r', findingOccurrenceId: 'x' }]),
|
||||||
|
{ sourceKind: 'environment-observation', sourceResultId: 'x' } as UnifiedResultProjection)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -119,18 +119,54 @@ describe('scanner first read', () => {
|
||||||
expect(headings(container)).toEqual(['Description', 'Rationale']);
|
expect(headings(container)).toEqual(['Description', 'Rationale']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Maester: counts only rows the source marks, keeps the test result out of the reader and links https documentation', () => {
|
it('Maester: the test result lead as the problem, source-marked row counts and the https documentation link', () => {
|
||||||
const item = record([['ID', 'MT.9001'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['ResultDetail', 'Synthetic native test result'],
|
const item = record([['ID', 'MT.9001'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['ResultDetail', 'Synthetic native test result'],
|
||||||
['Description', 'Synthetic description.'], ['HelpUrl', 'https://maester.dev/docs/tests/MT.9001']]);
|
['Description', 'Synthetic description.'], ['HelpUrl', 'https://maester.dev/docs/tests/MT.9001']]);
|
||||||
const results = sheet([['Policy', 'Status'], ['Synthetic A', '❌ Disabled'], ['Synthetic B', '✅ Enabled'], ['Synthetic C', 'Report-only']]);
|
const results = sheet([['Policy', 'Status'], ['Synthetic A', '❌ Disabled'], ['Synthetic B', '✅ Enabled'], ['Synthetic C', 'Report-only']]);
|
||||||
const { container, values } = render(item, 'maester', [results]);
|
const { container, values } = render(item, 'maester', [results]);
|
||||||
expect(values('.finding-first-read')).toEqual({ Results: '1 of 3 rows marked failed · 1 marked passed' });
|
expect(values('.finding-first-read')).toEqual({ Result: 'Synthetic native test result', Results: '1 of 3 rows marked failed · 1 marked passed' });
|
||||||
expect(container.querySelector('.reader-summary')?.textContent).not.toContain('Synthetic native test result');
|
|
||||||
const link = container.querySelector<HTMLAnchorElement>('.policy-next-step a')!;
|
const link = container.querySelector<HTMLAnchorElement>('.policy-next-step a')!;
|
||||||
expect(link.getAttribute('href')).toBe('https://maester.dev/docs/tests/MT.9001');
|
expect(link.getAttribute('href')).toBe('https://maester.dev/docs/tests/MT.9001');
|
||||||
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression 2026-09-28 (user decision): the Maester test result statement was missing from the reader;
|
||||||
|
// only its first prose paragraph is shown, verbatim, while headings and the impacted-resources table stay in Results.
|
||||||
|
it('Maester: opening a finding shows the first paragraph of the native test result as the problem', () => {
|
||||||
|
const detail = '## Test result\n\nYou have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.\n\n'
|
||||||
|
+ '#### Impacted resources\n\n| Status | Name | First detected |\n| :--- | :--- | :--- |\n| ❌ Fail | synthetic-a@example.test | 04/17/2025 |';
|
||||||
|
const item = record([['ID', 'MT.1024.adminMFAV2'], ['Title', 'Require multifactor authentication for administrative roles.'],
|
||||||
|
['Result', 'Failed'], ['ResultDetail', detail], ['Description', 'Synthetic description.\n\n#### Remediation action:\n\n1. Enable MFA.']]);
|
||||||
|
const { container, values } = render(item, 'maester');
|
||||||
|
expect(values('.finding-first-read')).toEqual({
|
||||||
|
Result: 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.'
|
||||||
|
});
|
||||||
|
const reader = container.querySelector('.reader-summary')?.textContent ?? '';
|
||||||
|
expect(reader).not.toContain('synthetic-a@example.test');
|
||||||
|
expect(reader).not.toContain('Impacted resources');
|
||||||
|
expect(item.fields.find(field => field.label === 'ResultDetail')?.value).toBe(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression 2026-09-28: the Maester Excel export keeps the test result in `Result` next to a separate
|
||||||
|
// `Status` column, with its line breaks flattened; the statement must still become the problem.
|
||||||
|
it.each([
|
||||||
|
['CIS.M365.1.1.1', ' Your tenant has 1 or more hybrid Global Administrators: | Display Name | Cloud Only | | --- | --- | | Synthetic A | ❌ Fail |',
|
||||||
|
'Your tenant has 1 or more hybrid Global Administrators:'],
|
||||||
|
['MT.1024.adminMFAV2', 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA. #### Impacted resources | Status | Name |',
|
||||||
|
'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.']
|
||||||
|
])('Maester export %s: the Result statement next to a Status column is the problem', (id, result, expected) => {
|
||||||
|
const item = record([['ID', id], ['Title', 'Synthetic finding'], ['Severity', 'High'], ['Status', 'Failed'],
|
||||||
|
['Category', 'CIS'], ['Description', 'Synthetic description.'], ['Result', result]]);
|
||||||
|
const { values } = render(item, 'maester');
|
||||||
|
expect(values('.finding-first-read')).toEqual({ Result: expected });
|
||||||
|
expect(item.fields.find(field => field.label === 'Result')?.value).toBe(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Maester export without a Status column keeps Result as the status, not as a problem statement', () => {
|
||||||
|
const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['Description', 'Synthetic description.']]);
|
||||||
|
expect(render(item, 'maester').container.querySelector('.finding-first-read.policy-problem')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
// Regression 2026-09-26: the mixed Work sheet carries the generic profile and names each record's scan in
|
// Regression 2026-09-26: the mixed Work sheet carries the generic profile and names each record's scan in
|
||||||
// `Source: Scan`. Purple Knight runs are named by scope, which did not resolve to the profile, so the
|
// `Source: Scan`. Purple Knight runs are named by scope, which did not resolve to the profile, so the
|
||||||
// report showed the fields as plain sections instead of Problem and Next step.
|
// report showed the fields as plain sections instead of Problem and Next step.
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,42 @@ async function commit(
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Unified result read model', () => {
|
describe('Unified result read model', () => {
|
||||||
|
it('serves the stored, checked Result DB unchanged until the data changes, with a trace and a client identity guard', async () => {
|
||||||
|
const repository = new LocalAssessorRepository();
|
||||||
|
await repository.saveProject(project);
|
||||||
|
await repository.saveAssessment(assessment);
|
||||||
|
expect(await repository.getResultDbStatus()).toMatchObject({ state: 'not-built', rowCount: 0 });
|
||||||
|
|
||||||
|
const scanRun = run('run-materialized', 1, 'pingcastle', 'PingCastle');
|
||||||
|
const first = definition('definition-host', 'pingcastle:a-dc-spooler', 'Anomalies');
|
||||||
|
const second = { ...definition('definition-other', 'pingcastle:a-other', 'Anomalies'),
|
||||||
|
description: 'The spooler service answers on SRVDC01 for 3 hosts.' };
|
||||||
|
const hostFinding = { ...occurrence('occurrence-host', scanRun, first.id, 'failed', 'FAILED', 'A-DC-Spooler'),
|
||||||
|
affectedAsset: { originalValue: 'SRVDC01', normalizedValue: 'SRVDC01', provenance } };
|
||||||
|
await commit(repository, scanRun, [first, second], [hostFinding,
|
||||||
|
occurrence('occurrence-other', scanRun, second.id, 'failed', 'FAILED', 'A-Other')]);
|
||||||
|
|
||||||
|
const firstRead = await repository.queryUnifiedResults({ projectId: project.id });
|
||||||
|
const status = await repository.getResultDbStatus();
|
||||||
|
expect(status).toMatchObject({ state: 'current', rowCount: 2, disclosurePolicyVersion: expect.any(String),
|
||||||
|
identityGuardVersion: expect.any(String) });
|
||||||
|
const other = firstRead.items.find(item => item.sourceResultId === 'occurrence-other')!;
|
||||||
|
// SRVDC01 is only the affected asset of another finding; the client guard still removes it here.
|
||||||
|
expect(other.description).toBe('The spooler service answers on [redacted] for [count] hosts.');
|
||||||
|
expect(other.disclosureTrace?.redactions).toEqual(expect.arrayContaining([
|
||||||
|
{ field: 'description', rule: 'identity-guard', count: 1 }, { field: 'description', rule: 'count', count: 1 }]));
|
||||||
|
expect(JSON.stringify(firstRead)).not.toContain('SRVDC01');
|
||||||
|
|
||||||
|
const secondRead = await repository.queryUnifiedResults({ projectId: project.id });
|
||||||
|
expect(secondRead).toEqual(firstRead);
|
||||||
|
expect((await repository.getResultDbStatus()).materializedAt).toBe(status.materializedAt);
|
||||||
|
|
||||||
|
await repository.saveProject({ ...project, name: 'Synthetic project renamed', updatedAt: timestamp });
|
||||||
|
expect(await repository.getResultDbStatus()).toMatchObject({ state: 'outdated' });
|
||||||
|
await repository.queryUnifiedResults({ projectId: project.id });
|
||||||
|
expect(await repository.getResultDbStatus()).toMatchObject({ state: 'current' });
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the native public PingCastle rule ID and report title through the normal Result DB query', async () => {
|
it('shows the native public PingCastle rule ID and report title through the normal Result DB query', async () => {
|
||||||
const repository = new LocalAssessorRepository();
|
const repository = new LocalAssessorRepository();
|
||||||
await repository.saveProject(project);
|
await repository.saveProject(project);
|
||||||
|
|
@ -225,11 +261,16 @@ describe('Unified result read model', () => {
|
||||||
isIoe: true,
|
isIoe: true,
|
||||||
exposureState: 'detected',
|
exposureState: 'detected',
|
||||||
lifecycleState: 'unknown',
|
lifecycleState: 'unknown',
|
||||||
reviewStatus: 'resolved',
|
|
||||||
reviewDisposition: 'accepted',
|
|
||||||
categories: [{ namespace: 'source-tool', categoryLabel: 'PingCastle' }]
|
categories: [{ namespace: 'source-tool', categoryLabel: 'PingCastle' }]
|
||||||
});
|
});
|
||||||
expect(first).not.toHaveProperty('reviewOwner');
|
// Client-owned review state is internal analysis only; the Result DB withholds it.
|
||||||
|
for (const key of ['reviewOwner', 'reviewStatus', 'reviewDisposition', 'reviewPriority']) {
|
||||||
|
expect(first).not.toHaveProperty(key);
|
||||||
|
}
|
||||||
|
const internal = await repository.queryUnifiedResults({ audience: 'internal-analysis', projectId: project.id });
|
||||||
|
expect(internal.items.find(item => item.sourceResultId === 'occurrence-1')).toMatchObject({
|
||||||
|
reviewStatus: 'resolved', reviewDisposition: 'accepted', reviewPriority: 'high'
|
||||||
|
});
|
||||||
|
|
||||||
const executionError = await repository.queryUnifiedResults({
|
const executionError = await repository.queryUnifiedResults({
|
||||||
outcome: ['execution_error'],
|
outcome: ['execution_error'],
|
||||||
|
|
@ -333,6 +374,13 @@ describe('Unified result read model', () => {
|
||||||
normalizedValue: 'private.synthetic.example', valueType: 'string', state: 'observed',
|
normalizedValue: 'private.synthetic.example', valueType: 'string', state: 'observed',
|
||||||
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/1' },
|
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/1' },
|
||||||
mappingVersion: '1.2.0+context-1.0.0', attributes: { customerMarker: 'private-synthetic' }
|
mappingVersion: '1.2.0+context-1.0.0', attributes: { customerMarker: 'private-synthetic' }
|
||||||
|
}, {
|
||||||
|
version: 1, id: 'environment-recycle-bin', scanRunId: scanRun.id, importFileId: importFile.id, tool: scanRun.tool,
|
||||||
|
subject: { type: 'domain-information', identity: 'private.synthetic.example' },
|
||||||
|
property: 'domain.recycle_bin_enabled', label: 'Recycle Bin', originalValue: 'Enabled',
|
||||||
|
normalizedValue: true, valueType: 'boolean', state: 'observed',
|
||||||
|
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/2' },
|
||||||
|
mappingVersion: '1.2.0+context-1.0.0', attributes: { customerMarker: 'private-synthetic' }
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
await session.commit();
|
await session.commit();
|
||||||
|
|
@ -340,34 +388,36 @@ describe('Unified result read model', () => {
|
||||||
const results = await repository.queryUnifiedResults({
|
const results = await repository.queryUnifiedResults({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
sourceKind: ['environment-observation'],
|
sourceKind: ['environment-observation'],
|
||||||
environmentProperty: ['domain.functional_level'],
|
environmentProperty: ['domain.recycle_bin_enabled'],
|
||||||
environmentSubjectType: ['active-directory-environment'],
|
environmentSubjectType: ['active-directory-environment'],
|
||||||
environmentState: ['observed'],
|
environmentState: ['observed']
|
||||||
search: '2016'
|
|
||||||
});
|
});
|
||||||
expect(results).toMatchObject({
|
expect(results).toMatchObject({
|
||||||
total: 1,
|
total: 1,
|
||||||
items: [expect.objectContaining({
|
items: [expect.objectContaining({
|
||||||
resultProjectionId: 'environment-observation:environment-dfl',
|
resultProjectionId: 'environment-observation:environment-recycle-bin',
|
||||||
sourceKind: 'environment-observation',
|
sourceKind: 'environment-observation',
|
||||||
sourceTool: 'pingcastle',
|
sourceTool: 'pingcastle',
|
||||||
sourceProfileVersion: '1.5.0',
|
sourceProfileVersion: '1.5.0',
|
||||||
mappingRuleVersion: '1.2.0+context-1.0.0',
|
mappingRuleVersion: '1.2.0+context-1.0.0',
|
||||||
processingStatus: 'current',
|
processingStatus: 'current',
|
||||||
environmentProperty: 'domain.functional_level',
|
environmentProperty: 'domain.recycle_bin_enabled',
|
||||||
environmentSubjectType: 'active-directory-environment',
|
environmentSubjectType: 'active-directory-environment',
|
||||||
environmentNormalizedValue: 2016,
|
environmentNormalizedValue: true,
|
||||||
environmentValueType: 'number',
|
environmentValueType: 'boolean',
|
||||||
environmentState: 'observed',
|
environmentState: 'observed',
|
||||||
supplementalDisclosurePolicyVersion: '1.0.0',
|
supplementalDisclosurePolicyVersion: '2.0.0',
|
||||||
supplementalDisclosureClass: 'technical-metadata',
|
supplementalDisclosureClass: 'configuration-state',
|
||||||
resultType: 'informational',
|
resultType: 'informational',
|
||||||
isIoe: false,
|
isIoe: false,
|
||||||
isDeviation: false
|
isDeviation: false
|
||||||
})]
|
})]
|
||||||
});
|
});
|
||||||
|
await expect(repository.getUnifiedResultById('environment-observation:environment-recycle-bin'))
|
||||||
|
.resolves.toMatchObject({ sourceResultId: 'environment-recycle-bin', environmentNormalizedValue: true });
|
||||||
|
// Functional levels, counts, scores and versions fingerprint the environment and stay internal.
|
||||||
await expect(repository.getUnifiedResultById('environment-observation:environment-dfl'))
|
await expect(repository.getUnifiedResultById('environment-observation:environment-dfl'))
|
||||||
.resolves.toMatchObject({ sourceResultId: 'environment-dfl', environmentNormalizedValue: 2016 });
|
.resolves.toBeNull();
|
||||||
await expect(repository.getUnifiedResultById('environment-observation:environment-domain-name'))
|
await expect(repository.getUnifiedResultById('environment-observation:environment-domain-name'))
|
||||||
.resolves.toBeNull();
|
.resolves.toBeNull();
|
||||||
expect(JSON.stringify(results)).not.toContain('synthetic.example');
|
expect(JSON.stringify(results)).not.toContain('synthetic.example');
|
||||||
|
|
@ -383,7 +433,7 @@ describe('Unified result read model', () => {
|
||||||
expect(facets.facets.find(facet => facet.id === 'sourceKind')?.values)
|
expect(facets.facets.find(facet => facet.id === 'sourceKind')?.values)
|
||||||
.toContainEqual(expect.objectContaining({ value: 'environment-observation', count: 1 }));
|
.toContainEqual(expect.objectContaining({ value: 'environment-observation', count: 1 }));
|
||||||
expect(facets.facets.find(facet => facet.id === 'environmentProperty')?.values)
|
expect(facets.facets.find(facet => facet.id === 'environmentProperty')?.values)
|
||||||
.toContainEqual(expect.objectContaining({ value: 'domain.functional_level', count: 1 }));
|
.toEqual([expect.objectContaining({ value: 'domain.recycle_bin_enabled', count: 1 })]);
|
||||||
expect(facets.facets.find(facet => facet.id === 'environmentState')?.values)
|
expect(facets.facets.find(facet => facet.id === 'environmentState')?.values)
|
||||||
.toContainEqual(expect.objectContaining({ value: 'observed', count: 1 }));
|
.toContainEqual(expect.objectContaining({ value: 'observed', count: 1 }));
|
||||||
expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values)
|
expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values)
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,12 @@ describePostgres('PostgresAssessorRepository integration', () => {
|
||||||
technicalExecutionState: 'completed'
|
technicalExecutionState: 'completed'
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
await expect(repository.getUnifiedResultById('scanner-finding:pg-occurrence-1')).resolves.toMatchObject({
|
// Client-owned review state stays in internal analysis; the Result DB withholds it.
|
||||||
|
const disclosed = await repository.getUnifiedResultById('scanner-finding:pg-occurrence-1');
|
||||||
|
expect(disclosed).not.toHaveProperty('reviewStatus');
|
||||||
|
expect(disclosed).not.toHaveProperty('reviewPriority');
|
||||||
|
const internal = await repository.queryUnifiedResults({ audience: 'internal-analysis', projectId: project.id });
|
||||||
|
expect(internal.items.find(item => item.sourceResultId === 'pg-occurrence-1')).toMatchObject({
|
||||||
reviewStatus: 'open',
|
reviewStatus: 'open',
|
||||||
reviewPriority: 'high'
|
reviewPriority: 'high'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { databaseMigrations } from '../../server/postgres/migration-catalog';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..', '..', 'server', 'postgres');
|
||||||
|
|
||||||
|
describe('PostgreSQL materialized Result DB migration', () => {
|
||||||
|
it('is registered as an additive migration after reference recognition', () => {
|
||||||
|
expect(databaseMigrations.find(migration => migration.version === '024')).toMatchObject({
|
||||||
|
version: '024', name: 'result-db-materialization'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores derived rows per client with cascade deletion and one state record', async () => {
|
||||||
|
const sql = await readFile(resolve(root, 'migrations', '024_result_db_materialization.sql'), 'utf8');
|
||||||
|
expect(sql).toContain('CREATE TABLE assessor_result_db_rows');
|
||||||
|
expect(sql).toContain('REFERENCES assessor_projects(id) ON DELETE CASCADE');
|
||||||
|
expect(sql).toContain('CREATE TABLE assessor_result_db_state');
|
||||||
|
expect(sql).not.toMatch(/\bDROP\b|\bALTER TABLE assessor_(?!result_db)/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves every Result DB read from the stored rows and rebuilds after committed changes', async () => {
|
||||||
|
const repository = await readFile(resolve(root, 'postgres-assessor-repository.ts'), 'utf8');
|
||||||
|
expect(repository).toContain("if (query.audience !== 'internal-analysis') return this.storedResultDbRows(query.projectId);");
|
||||||
|
expect(repository).toContain("'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1'");
|
||||||
|
expect(repository).toContain('if (changed(result)) this.scheduleResultDbRebuild();');
|
||||||
|
expect(repository).toContain('pg_advisory_lock($1)');
|
||||||
|
expect(repository).not.toContain('discloseResultDbProjection(');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
|
||||||
|
|
||||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||||
expect(await health.json()).toEqual({
|
expect(await health.json()).toEqual({
|
||||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.8.7', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
status: 'ok', application: 'ASSESSOR', productVersion: '2.9.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||||
});
|
});
|
||||||
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -461,7 +461,9 @@ describe('baseline-driven policy evaluation', () => {
|
||||||
policyBaselineVariantAssessment: saved.results[0].baselineVariantAssessment
|
policyBaselineVariantAssessment: saved.results[0].baselineVariantAssessment
|
||||||
});
|
});
|
||||||
const html = renderToStaticMarkup(createElement(ResultRow, { item: unified, expanded: true, onToggle() {} }));
|
const html = renderToStaticMarkup(createElement(ResultRow, { item: unified, expanded: true, onToggle() {} }));
|
||||||
expect(html).toContain('Setting reference:');
|
// The Result DB lists collapsed baseline expectations with their relation, never customer values.
|
||||||
|
expect(html).toContain('Value comparison:');
|
||||||
|
expect(html).not.toContain('Configured customer value');
|
||||||
expect(html).toContain('No effective device state');
|
expect(html).toContain('No effective device state');
|
||||||
expect(html).toContain('Configured Group Policy value compared with the retained baseline recommendation.');
|
expect(html).toContain('Configured Group Policy value compared with the retained baseline recommendation.');
|
||||||
// The saved per-variant values and verdicts remain available consistently in Work guidance
|
// The saved per-variant values and verdicts remain available consistently in Work guidance
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue