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:
Michael 2026-09-29 14:29:52 +02:00
parent d8f131d6da
commit 0fe283d68f
46 changed files with 2193 additions and 197 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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

4
package-lock.json generated
View File

@ -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",

View File

@ -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": {

View File

@ -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;
} }

View File

@ -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 }
]; ];

View File

@ -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
);

View File

@ -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[]> {

View File

@ -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 }));

View File

@ -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>

View File

@ -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(/&quot;/giu, '"') .replace(/&quot;/giu, '"')
.replace(/&#39;/giu, "'"); .replace(/&#39;/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,

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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 }] : [];
});
}

View File

@ -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. */

View File

@ -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()

View File

@ -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(

View File

@ -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) {

View File

@ -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>;
}

View File

@ -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);

View File

@ -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];
}

View File

@ -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>;

View File

@ -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> {

View File

@ -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.

View File

@ -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"
] ]
} }

View File

@ -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; }

View File

@ -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;

View File

@ -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';");

View File

@ -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');
});
}); });

View File

@ -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);
}
});
});

View File

@ -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' });

View File

@ -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);
}); });
}); });

View File

@ -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);

View File

@ -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');

View File

@ -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()); }
});
});

View File

@ -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()); }

View File

@ -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([]);
});
});

View File

@ -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.

View File

@ -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)

View File

@ -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'
}); });

View File

@ -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(');
});
});

View File

@ -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();
}); });

View File

@ -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