2.9.0: privacy-compliant Result DB, materialized rows and disclosure policy 3.4.0
- Result DB is checked and stored on creation (migration 024, assessor_result_db_rows) with a content-free per-row trace, per-client identity guard and Open original in Work - Disclosure policy 3.4.0: review state no longer revealed via withheld-field trace, per-finding scanner scores withheld, CIS numbers keep all seven levels - Less over-redaction: "Limited" is no legal form, attached versions (Tls1.2, v1.0), Windows 95, 2012-R2, step/option/page references, documented Microsoft GUIDs and documented vendor values in the recommendation field stay - Maester first read shows the native result statement verbatim Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8f131d6da
commit
0fe283d68f
File diff suppressed because one or more lines are too long
|
|
@ -77,6 +77,13 @@ ein Rollback ohne Teilimport.
|
|||
Migration `002_import_fingerprints.sql` ergänzt `assessor_import_fingerprints`. Der SHA-256-Wert ist Primärschlüssel; gespeichert werden außerdem Dateiname, Größe, letzter Dateiänderungszeitpunkt, erster und letzter Importzeitpunkt sowie ein Wiederholungszähler.
|
||||
|
||||
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
|
||||
|
||||
Vor Migrationen, Updates und größeren Löschvorgängen ist ein logisches Backup
|
||||
|
|
|
|||
|
|
@ -486,6 +486,63 @@ und Artefaktidentitaeten sowie Zeitpunkte. Manifest-Scope und Filter werden nur
|
|||
als Boolesche beziehungsweise Mengenangaben ausgegeben; Suchtext, Assetfilter
|
||||
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
|
||||
|
||||
`CANONICAL_ASSET`, `ASSET_EDGE`, `ASSET_PROPERTY` und `EDGE_COVERAGE` wurden
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.8.7",
|
||||
"version": "2.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "assessor",
|
||||
"version": "2.8.7",
|
||||
"version": "2.9.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.8.7",
|
||||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -987,6 +987,9 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
|||
if (segments.length === 1 && segments[0] === 'results' && method === 'GET') {
|
||||
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') {
|
||||
status = 200; sendJson(response, status, await repository.queryUnifiedResultFacets(unifiedQueryFrom(url)), requestId); return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import scannerEnvironmentContext from './migrations/018_scanner_environment_cont
|
|||
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
|
||||
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
|
||||
import referenceRecognition from './migrations/023_reference_recognition.sql?raw';
|
||||
import resultDbMaterialization from './migrations/024_result_db_materialization.sql?raw';
|
||||
|
||||
export interface DatabaseMigration {
|
||||
version: string;
|
||||
|
|
@ -47,5 +48,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
|||
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
|
||||
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
|
||||
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation },
|
||||
{ version: '023', name: 'reference-recognition', sql: referenceRecognition }
|
||||
{ version: '023', name: 'reference-recognition', sql: referenceRecognition },
|
||||
{ version: '024', name: 'result-db-materialization', sql: resultDbMaterialization }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
-- Materialized Result DB (user decision 2026-09-28): every row is disclosed, checked and stored when
|
||||
-- its source data changes, so UI, API and JSONL export read exactly the same verified content.
|
||||
-- Derived data only: rows are rebuilt from canonical tables and never edited by hand.
|
||||
CREATE TABLE assessor_result_db_rows (
|
||||
result_projection_id text PRIMARY KEY CHECK (btrim(result_projection_id) <> ''),
|
||||
ordinal integer NOT NULL CHECK (ordinal >= 0),
|
||||
project_id text NOT NULL REFERENCES assessor_projects(id) ON DELETE CASCADE,
|
||||
source_kind text NOT NULL CHECK (btrim(source_kind) <> ''),
|
||||
row_data jsonb NOT NULL CHECK (jsonb_typeof(row_data) = 'object'),
|
||||
guard_withheld boolean NOT NULL DEFAULT false,
|
||||
source_revision bigint NOT NULL
|
||||
);
|
||||
CREATE INDEX assessor_result_db_rows_project_idx ON assessor_result_db_rows(project_id, ordinal);
|
||||
CREATE INDEX assessor_result_db_rows_ordinal_idx ON assessor_result_db_rows(ordinal);
|
||||
|
||||
-- One state record describes the stored Result DB as a whole.
|
||||
CREATE TABLE assessor_result_db_state (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
source_revision bigint NOT NULL,
|
||||
disclosure_policy_version text NOT NULL,
|
||||
identity_guard_version text NOT NULL,
|
||||
row_count integer NOT NULL CHECK (row_count >= 0),
|
||||
redaction_count integer NOT NULL CHECK (redaction_count >= 0),
|
||||
guard_withheld_row_count integer NOT NULL CHECK (guard_withheld_row_count >= 0),
|
||||
materialized_at timestamptz NOT NULL
|
||||
);
|
||||
|
|
@ -55,7 +55,12 @@ import {
|
|||
type UnifiedResultQuery,
|
||||
type UnifiedResultQueryResult
|
||||
} 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 { deriveExposureState, deriveIsIoe, deriveResultType } from '../../src/domain/result-contract-mapping';
|
||||
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> {
|
||||
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.');
|
||||
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 {
|
||||
|
|
@ -426,6 +434,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
|||
this.cachedRevision = databaseRevision;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
if (changed(result)) this.scheduleResultDbRebuild();
|
||||
return result;
|
||||
} catch (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[]> {
|
||||
// Public tool identities are rebuilt by the Result DB disclosure policy.
|
||||
// Do not prefilter on the untrusted source value before that mapping; the
|
||||
// shared in-memory filter below applies the public value consistently.
|
||||
const sourceQuery = query.audience === 'internal-analysis' ? query : { ...query, sourceTool: undefined };
|
||||
// The Result DB is served only from the stored, disclosure-checked rows (see materializeResultDb).
|
||||
if (query.audience !== 'internal-analysis') return this.storedResultDbRows(query.projectId);
|
||||
const [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([
|
||||
this.unifiedScannerResults(sourceQuery),
|
||||
this.unifiedEnvironmentResults(sourceQuery),
|
||||
this.unifiedPolicyObservationResults(sourceQuery),
|
||||
this.unifiedPolicyConsistencyResults(sourceQuery),
|
||||
this.unifiedPolicyEvaluationComparisonResults(sourceQuery),
|
||||
this.unifiedPolicyResults(sourceQuery)
|
||||
this.unifiedScannerResults(query),
|
||||
this.unifiedEnvironmentResults(query),
|
||||
this.unifiedPolicyObservationResults(query),
|
||||
this.unifiedPolicyConsistencyResults(query),
|
||||
this.unifiedPolicyEvaluationComparisonResults(query),
|
||||
this.unifiedPolicyResults(query)
|
||||
]);
|
||||
const results = [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
|
||||
return query.audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection);
|
||||
return [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy];
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
@ -1253,27 +1394,10 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
|||
}
|
||||
|
||||
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> {
|
||||
const scannerPrefix = 'scanner-finding:';
|
||||
const policyPrefix = 'policy-evaluation:';
|
||||
const policyComparisonPrefix = 'policy-evaluation-comparison:';
|
||||
const policyObservationPrefix = 'policy-observation:';
|
||||
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;
|
||||
await this.ensureResultDb();
|
||||
const result = await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>(
|
||||
'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1', [resultProjectionId]);
|
||||
return result.rows[0] ? removeResultDbScopeIdentifiers(result.rows[0].row_data) : null;
|
||||
}
|
||||
|
||||
async listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { ImportedDataset } from '../domain/dataset';
|
|||
import type { ReportProjection } from '../domain/report-projection';
|
||||
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
|
||||
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
|
||||
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||
import {
|
||||
ASSESSOR_API_PREFIX,
|
||||
ASSESSOR_MUTATION_HEADER,
|
||||
|
|
@ -227,6 +228,10 @@ export class AssessorApiClient {
|
|||
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> {
|
||||
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/timeslot-result-metrics`,
|
||||
{ scanRunIds, assessmentIds }));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DataHub } from '../features/project-import/DataHub';
|
|||
import { DashboardPanel } from '../features/dashboard/DashboardPanel';
|
||||
import { DbStructurePanel } from '../features/db-structure/DbStructurePanel';
|
||||
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 { useFilteredRecords } from '../features/search-filter/use-filtered-records';
|
||||
import { DatasetViewer } from '../features/dataset-viewer/DatasetViewer';
|
||||
|
|
@ -192,7 +193,9 @@ export function App() {
|
|||
)}
|
||||
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={openWork}/>}
|
||||
{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>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import catalog from '../profiles/public-search/catalog.json';
|
|||
import type { JsonValue } from './canonical-model';
|
||||
import type { PolicyBaselineVariantAssessment } from './policy-evaluation';
|
||||
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';
|
||||
|
||||
|
|
@ -34,6 +37,17 @@ function disclosureTool(item: UnifiedResultProjection): { id: string; label: str
|
|||
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 {
|
||||
const folded = value?.trim().toLocaleLowerCase('en-US');
|
||||
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;
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +78,8 @@ const publicDomainNames = new Set([
|
|||
'microsoft.com', 'windows.com', 'office.com', 'microsoftonline.com', 'azure.com', 'cisecurity.org'
|
||||
]);
|
||||
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([
|
||||
'domain admins', 'domain users', 'domain guests', 'domain computers', 'domain controllers',
|
||||
'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,
|
||||
match => this.principalToken(match));
|
||||
if (redactAccountNames) {
|
||||
redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, match => {
|
||||
if (publicAccountPrefixes.test(match)) return match;
|
||||
redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, (match, offset: number, input: string) => {
|
||||
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 principal = match.slice(separator + 1);
|
||||
return publicDomainRoles.has(principal.toLocaleLowerCase('en-US'))
|
||||
|
|
@ -120,7 +139,7 @@ class PolicyValueRedactor {
|
|||
redacted = redacted
|
||||
.replace(/\\\\[^\s\\]+\\[^\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 =>
|
||||
publicDomainNames.has(match.toLocaleLowerCase('en-US')) ? match : this.domainToken(match));
|
||||
return redacted;
|
||||
|
|
@ -153,11 +172,22 @@ function safePolicyNarrative(item: UnifiedResultProjection, redactor: PolicyValu
|
|||
return result || undefined;
|
||||
}
|
||||
|
||||
function safeBaselineLabel(item: UnifiedResultProjection, redactor: PolicyValueRedactor): string {
|
||||
const label = item.policyBaselineLabel?.trim();
|
||||
if (!label) return 'Baseline recommendation';
|
||||
const publicPart = /\b(?:Microsoft|Windows|CIS|ANSSI|DISA|NCSC)\b.*$/iu.exec(label)?.[0];
|
||||
return redactor.text(publicPart ?? 'Baseline recommendation');
|
||||
const baselineFamilies: Array<[RegExp, string]> = [
|
||||
[/\bWindows\s+Server\b/iu, 'Microsoft Security Baseline - Windows Server'],
|
||||
[/\bWindows\s+(?:1[01]|client)\b/iu, 'Microsoft Security Baseline - Windows Client'],
|
||||
[/\b(?:Microsoft\s+365|Office)\b/iu, 'Microsoft Security Baseline - Microsoft 365 Apps'],
|
||||
[/\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(
|
||||
|
|
@ -165,32 +195,261 @@ function safeBaselineVariantAssessment(
|
|||
redactor: PolicyValueRedactor
|
||||
): PolicyBaselineVariantAssessment | 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 {
|
||||
version: 1,
|
||||
scope: 'backup-configuration',
|
||||
decision: assessment.decision,
|
||||
actualValues: redactor.json(assessment.actualValues as unknown as JsonValue) as unknown as PolicyBaselineVariantAssessment['actualValues'],
|
||||
variants: assessment.variants.map((variant, index) => ({
|
||||
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) ?? []
|
||||
}
|
||||
})),
|
||||
actualValues: [],
|
||||
variants: [...expectations.values()],
|
||||
diagnosticCodes: controlledDiagnosticCodes(assessment.diagnosticCodes) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -225,7 +484,11 @@ function privateLiterals(item: UnifiedResultProjection): string[] {
|
|||
* Keeps rule guidance useful while removing identifiers that scanner prose can repeat.
|
||||
* 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;
|
||||
let redacted = value.trim()
|
||||
.replace(/<(?:br|\/p|\/li|\/div)\s*\/?>/giu, '\n')
|
||||
|
|
@ -237,33 +500,79 @@ function redactNarrative(item: UnifiedResultProjection, value?: string): string
|
|||
.replace(/"/giu, '"')
|
||||
.replace(/'/giu, "'");
|
||||
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
|
||||
// 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(/\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]')
|
||||
.replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]')
|
||||
// A documented Microsoft GUID is held out like a public reference, so its digit groups are not counts.
|
||||
.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) => {
|
||||
const start = input.lastIndexOf('\n', offset) + 1;
|
||||
const end = input.indexOf('\n', offset);
|
||||
const line = input.slice(start, end < 0 ? undefined : end).trim();
|
||||
return cisRuleId && /^(?:Computer|User) Configuration\\Administrative Templates\\/iu.test(line)
|
||||
&& publicCisPolicyPathPairs.has(match.toLocaleLowerCase('en-US'))
|
||||
? match : '[account]';
|
||||
// Inside a path, or the direct continuation of a path the source hard-wrapped mid-segment
|
||||
// ("…\Terminal" + newline + "Server\UserConfig"): the span ends in a segment character and
|
||||
// the match starts the very next line.
|
||||
if (pathSpans.some(([start, end]) => offset >= start && (offset < end
|
||||
|| (/^\r?\n$/u.test(input.slice(end, offset)) && /[\p{L}\p{N}]/u.test(input[end - 1] ?? ''))))) return match;
|
||||
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(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]')
|
||||
.replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()]+/giu, '[url]')
|
||||
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, match => cisRuleId === match ? match : '[ip]')
|
||||
.replace(ipv4Address, (match, offset: number, input: string) =>
|
||||
(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(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match => cisRuleId && /\.(?:admx|adml)$/iu.test(match) ? match : '[domain]')
|
||||
.replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?|Limited)\b/giu, '[organization]');
|
||||
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, (match, offset: number, input: string) => {
|
||||
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)) {
|
||||
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
|
||||
.replace(/(?:\[redacted\]\s*){2,}/gu, '[redacted] ')
|
||||
.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(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
|
@ -298,13 +607,13 @@ function controlledTitle(item: UnifiedResultProjection): string {
|
|||
}
|
||||
const tool = disclosureTool(item);
|
||||
const ruleId = safePublicRuleId(item);
|
||||
const title = redactNarrative(item, item.title);
|
||||
const title = redactNarrative(item, item.title, 'title');
|
||||
if (title) return title;
|
||||
return ruleId ? `${tool.label} public rule ${ruleId}` : `${tool.label} finding`;
|
||||
}
|
||||
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
* 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 policyEvaluation = item.sourceKind === 'policy-evaluation';
|
||||
const policyRedactor = policyEvaluation ? new PolicyValueRedactor() : undefined;
|
||||
const rawPublicRuleId = safePublicRuleId(item);
|
||||
const publicRuleId = policyRedactor && rawPublicRuleId ? policyRedactor.text(rawPublicRuleId, false) : rawPublicRuleId;
|
||||
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 publicBaselineVariants = policyRedactor
|
||||
? safeBaselineVariantAssessment(item.policyBaselineVariantAssessment, policyRedactor)
|
||||
|
|
@ -357,18 +776,21 @@ export function discloseResultDbProjection(item: UnifiedResultProjection): Unifi
|
|||
policySourceSequence: undefined,
|
||||
policyIdentityKey: undefined,
|
||||
policyBaselineId: undefined,
|
||||
policyBaselineLabel: policyRedactor ? safeBaselineLabel(item, policyRedactor) : undefined,
|
||||
policyBaselineLabel: policyRedactor ? safeBaselineLabel(item) : undefined,
|
||||
policyBaselineValue: publicBaselineValue,
|
||||
policyBaselineVariantAssessment: publicBaselineVariants,
|
||||
policyActualValue: publicActualValue,
|
||||
policyActualValue: undefined,
|
||||
policySourceCount: undefined,
|
||||
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
|
||||
? [policyDescription, controlledDescription(item)].filter(Boolean).join(' ')
|
||||
: controlledDescription(item),
|
||||
recommendation: policyRedactor
|
||||
? safePolicyNarrative(item, policyRedactor, item.recommendation)
|
||||
: redactNarrative(item, item.recommendation),
|
||||
: redactNarrative(item, item.recommendation, 'recommendation'),
|
||||
affectedAsset: undefined,
|
||||
severityOriginal: item.severityNormalized,
|
||||
outcomeOriginal: item.outcomeNormalized,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import type { AssessmentProject, JsonValue } from './canonical-model';
|
||||
import type { EnvironmentObservation } from './environment-context';
|
||||
import type { UnifiedResultProjection } from './unified-result';
|
||||
|
||||
/**
|
||||
* Client-specific identity guard for the Result DB (user decision 2026-09-28).
|
||||
*
|
||||
* Pattern-based redaction cannot recognize a host name such as `DC01` or a person's name in free
|
||||
* text. The guard therefore collects the identities a client's own data already names (client name,
|
||||
* domain and tenant names, host and account names, affected assets, review owners) and removes them
|
||||
* from every disclosed Result DB row of that client, independent of their textual form. It runs inside
|
||||
* the disclosure projection, so API queries, single reads, the UI and the JSONL export are all covered.
|
||||
*/
|
||||
|
||||
export const RESULT_DB_IDENTITY_GUARD_VERSION = '1.3.0';
|
||||
|
||||
export interface ResultDbIdentitySources {
|
||||
projects: AssessmentProject[];
|
||||
environmentObservations: EnvironmentObservation[];
|
||||
affectedAssets: Array<string | undefined>;
|
||||
reviewOwners: Array<string | undefined>;
|
||||
/** Account-like values from internal analysis (`DOMAIN\user`, UPNs, GPO principals). */
|
||||
principals?: Array<string | undefined>;
|
||||
}
|
||||
|
||||
/** Generic words that must never become client identities, even when a client uses them as a label. */
|
||||
const genericWords = new Set([
|
||||
'local', 'corp', 'domain', 'domains', 'forest', 'tenant', 'admin', 'admins', 'administrator', 'administrators',
|
||||
'user', 'users', 'guest', 'guests', 'default', 'server', 'servers', 'computer', 'computers', 'windows',
|
||||
'microsoft', 'system', 'service', 'services', 'network', 'global', 'internal', 'intern', 'office', 'group',
|
||||
'groups', 'account', 'accounts', 'builtin', 'authority', 'everyone', 'unknown', 'none', 'true', 'false',
|
||||
// Documented built-in accounts and policies named in public scanner guidance.
|
||||
'krbtgt', 'defaultaccount', 'wdagutilityaccount', 'localhost', 'default domain policy',
|
||||
'default domain controllers policy', 'domain controllers', 'domain admins', 'enterprise admins', 'schema admins',
|
||||
// Well-known Active Directory groups, named in public guidance for every client.
|
||||
'server operators', 'account operators', 'backup operators', 'print operators', 'dns admins', 'dnsadmins',
|
||||
'dnsupdateproxy', 'cert publishers', 'protected users', 'remote desktop users', 'group policy creator owners',
|
||||
'key admins', 'enterprise key admins', 'read-only domain controllers', 'cloneable domain controllers',
|
||||
'domain users', 'domain computers', 'domain guests', 'pre-windows 2000 compatible access',
|
||||
// Scanner and product names.
|
||||
'pingcastle', 'maester', 'purple knight', 'purpleknight', 'cis-cat'
|
||||
]);
|
||||
|
||||
const publicAuthorities = /^(?:BUILTIN|NT AUTHORITY|NT SERVICE|NT VIRTUAL MACHINE|RESTRICTED SERVICES|Window Manager|Font Driver Host)$/iu;
|
||||
|
||||
/** Environment properties whose values name the client (domain, forest, tenant, host). */
|
||||
// Only the client's own domain, forest, host and tenant properties; other `*.name` properties (for
|
||||
// example certificate template names) hold public object names.
|
||||
const identityProperty = /^(?:domain|forest|host|computer|dc|entra|tenant)\.(?:fqdn|name|netbios|netbios_name|hostname|dns_name|tenant_name|tenant_id|domain)$/iu;
|
||||
|
||||
function normalized(value: string): string {
|
||||
return value.toLocaleLowerCase('en-US').replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
}
|
||||
|
||||
function usable(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length >= 3 && trimmed.length <= 200 && !/^\d+$/u.test(trimmed)
|
||||
&& !genericWords.has(trimmed.toLocaleLowerCase('en-US')) && !/^S-1-\d/iu.test(trimmed);
|
||||
}
|
||||
|
||||
function expand(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
const parts = [trimmed];
|
||||
const account = /^([^\\/@\s]+)\\(.+)$/u.exec(trimmed);
|
||||
if (account) {
|
||||
if (!publicAuthorities.test(account[1])) parts.push(account[1], account[2]);
|
||||
else return [];
|
||||
}
|
||||
const upn = /^([^@\s]+)@([^@\s]+\.[^@\s]+)$/u.exec(trimmed);
|
||||
if (upn) parts.push(upn[1], upn[2]);
|
||||
for (const part of [...parts]) {
|
||||
// The first label of a host or domain name (`sevenkingdoms` of `sevenkingdoms.local`) names the client.
|
||||
const labels = part.split('.');
|
||||
if (labels.length > 1 && /^[\p{L}\p{N}-]+$/u.test(labels[0]) && labels[0].length >= 5) parts.push(labels[0]);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host, account and domain names: a digit (`DC01`), a dot, backslash or `@` (`corp.local`,
|
||||
* `CORP\\jon`, `jon@corp.local`), or an all-lowercase sAMAccountName (`tywin`). Capitalized words and
|
||||
* phrases without such marks are categories or public object names and are not taken from asset columns.
|
||||
*/
|
||||
function identityShaped(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (/\s/u.test(trimmed) && !/[\\@.]/u.test(trimmed)) return false;
|
||||
return /\d|[.\\@]/u.test(trimmed) || /^[a-z][a-z0-9_-]{3,}$/u.test(trimmed);
|
||||
}
|
||||
|
||||
function strings(value: JsonValue | undefined): string[] {
|
||||
if (typeof value === 'string') return [value];
|
||||
if (Array.isArray(value)) return value.flatMap(strings);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Longest first, so `sevenkingdoms.local` is replaced before `sevenkingdoms`. */
|
||||
export function collectResultDbIdentityLiterals(sources: ResultDbIdentitySources): string[] {
|
||||
const candidates: string[] = [];
|
||||
for (const project of sources.projects) {
|
||||
candidates.push(project.name);
|
||||
for (const value of Object.values(project.attributes ?? {})) candidates.push(...strings(value));
|
||||
}
|
||||
for (const observation of sources.environmentObservations) {
|
||||
// Only identity properties name the client; subject and section labels are report vocabulary.
|
||||
if (!identityProperty.test(observation.property)) continue;
|
||||
candidates.push(...strings(observation.originalValue), ...strings(observation.normalizedValue));
|
||||
if (observation.subject.identity) candidates.push(observation.subject.identity);
|
||||
}
|
||||
// Scanner asset columns also hold categories and public object names ("Registry", "SubCA");
|
||||
// only values shaped like an identity are taken from them.
|
||||
for (const value of sources.affectedAssets) if (value && identityShaped(value)) candidates.push(value);
|
||||
for (const value of [...sources.reviewOwners, ...(sources.principals ?? [])]) if (value) candidates.push(value);
|
||||
const unique = new Map<string, string>();
|
||||
for (const candidate of candidates.flatMap(expand)) {
|
||||
const trimmed = candidate.trim();
|
||||
if (!usable(trimmed)) continue;
|
||||
const key = trimmed.toLocaleLowerCase('en-US');
|
||||
if (!unique.has(key)) unique.set(key, trimmed);
|
||||
}
|
||||
return [...unique.values()].sort((left, right) => right.length - left.length || left.localeCompare(right));
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export class ResultDbIdentityGuard {
|
||||
private readonly patterns: RegExp[];
|
||||
private readonly variantPatterns: RegExp[];
|
||||
|
||||
constructor(literals: readonly string[]) {
|
||||
this.patterns = literals.map(literal =>
|
||||
new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(literal)}(?![\\p{L}\\p{N}])`, 'giu'));
|
||||
// The fail-closed check tolerates separators between the characters (`seven-kingdoms`, `DC 01`)
|
||||
// but, like the exact pass, only matches whole tokens, never a fragment of another word.
|
||||
this.variantPatterns = [...new Set(literals.map(normalized).filter(value => value.length >= 5))]
|
||||
.map(value => new RegExp(`(?<![\\p{L}\\p{N}])${[...value].map(escapeRegExp).join('[\\s._\\\\/-]*')}(?![\\p{L}\\p{N}])`, 'iu'));
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.patterns.length;
|
||||
}
|
||||
|
||||
/** Replaces every known identity; a field that still contains one in another form is withheld. */
|
||||
text(value: string): string {
|
||||
let result = value;
|
||||
for (const pattern of this.patterns) result = result.replace(pattern, '[redacted]');
|
||||
return this.variantPatterns.some(pattern => pattern.test(result)) ? '[withheld by identity guard]' : result;
|
||||
}
|
||||
|
||||
json<T>(value: T): T {
|
||||
if (typeof value === 'string') return this.text(value) as T;
|
||||
if (Array.isArray(value)) return value.map(item => this.json(item)) as T;
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, this.json(child)])) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Account forms only: `DOMAIN\name` with exactly one backslash and no registry root, or a UPN.
|
||||
* GPO values also hold registry keys and paths (`Software\Policies\…`), which name no client.
|
||||
*/
|
||||
function accountLike(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
const account = /^([^\\\s]+)\\([^\\]+)$/u.exec(trimmed);
|
||||
if (account) return !/^(?:HK[A-Z_]*|HKEY_\w+|Software|System|Machine|User|Policies|Registry|Classes)$/iu.test(account[1]);
|
||||
return /^[^@\s]+@[^@\s]+\.[a-z]{2,}$/iu.test(trimmed);
|
||||
}
|
||||
|
||||
function accountLikeStrings(value: unknown): string[] {
|
||||
if (typeof value === 'string') return accountLike(value) ? [value] : [];
|
||||
if (Array.isArray(value)) return value.flatMap(accountLikeStrings);
|
||||
if (value && typeof value === 'object') return Object.values(value).flatMap(accountLikeStrings);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one guard per client from its complete internal data. `internalResults` must be the
|
||||
* unfiltered internal-analysis projection of those clients, never a filtered query page.
|
||||
*/
|
||||
export function buildResultDbIdentityGuards(input: {
|
||||
projects: AssessmentProject[];
|
||||
environmentObservations: Array<{ projectId: string; observation: EnvironmentObservation }>;
|
||||
internalResults: UnifiedResultProjection[];
|
||||
}): Map<string, ResultDbIdentityGuard> {
|
||||
const guards = new Map<string, ResultDbIdentityGuard>();
|
||||
for (const project of input.projects) {
|
||||
const results = input.internalResults.filter(item => item.projectId === project.id);
|
||||
guards.set(project.id, new ResultDbIdentityGuard(collectResultDbIdentityLiterals({
|
||||
projects: [project],
|
||||
environmentObservations: input.environmentObservations
|
||||
.filter(entry => entry.projectId === project.id).map(entry => entry.observation),
|
||||
affectedAssets: results.map(item => item.affectedAsset),
|
||||
reviewOwners: results.map(item => item.reviewOwner),
|
||||
principals: results.flatMap(item => [
|
||||
...accountLikeStrings(item.policyActualValue), ...accountLikeStrings(item.policyValues),
|
||||
...accountLikeStrings(item.environmentSubjectIdentity)
|
||||
])
|
||||
})));
|
||||
}
|
||||
return guards;
|
||||
}
|
||||
|
||||
/** Content fields of a disclosed row; identifiers, enums and version fields are never rewritten. */
|
||||
const guardedFields = [
|
||||
'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue',
|
||||
'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories'
|
||||
] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>;
|
||||
|
||||
export function applyResultDbIdentityGuard(item: UnifiedResultProjection, guard?: ResultDbIdentityGuard): UnifiedResultProjection {
|
||||
if (!guard?.size) return item;
|
||||
const guarded: UnifiedResultProjection = { ...item };
|
||||
for (const field of guardedFields) {
|
||||
if (guarded[field] !== undefined) (guarded as unknown as Record<string, unknown>)[field] = guard.json(guarded[field]);
|
||||
}
|
||||
return guarded;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { discloseResultDbProjectionWithTrace, RESULT_DB_DISCLOSURE_POLICY_VERSION } from './result-db-disclosure';
|
||||
import { RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard';
|
||||
import type { UnifiedResultProjection } from './unified-result';
|
||||
|
||||
/**
|
||||
* Materialized Result DB (user decision 2026-09-28): rows are disclosed, checked and stored when the
|
||||
* source data changes. UI, API and JSONL export read only these stored rows, so what is shown is
|
||||
* exactly what is delivered; each row carries its content-free redaction trace.
|
||||
*/
|
||||
export interface ResultDbMaterializationStatus {
|
||||
/** `current`: stored rows match the current repository revision and policy versions. */
|
||||
state: 'current' | 'outdated' | 'not-built';
|
||||
sourceRevision?: number;
|
||||
repositoryRevision: number;
|
||||
disclosurePolicyVersion?: string;
|
||||
identityGuardVersion?: string;
|
||||
materializedAt?: string;
|
||||
rowCount: number;
|
||||
redactionCount: number;
|
||||
/** Rows in which the identity guard withheld at least one complete field. */
|
||||
guardWithheldRowCount: number;
|
||||
}
|
||||
|
||||
export interface MaterializedResultDb {
|
||||
rows: UnifiedResultProjection[];
|
||||
status: ResultDbMaterializationStatus;
|
||||
}
|
||||
|
||||
export function materializeResultDb(
|
||||
sourceResults: UnifiedResultProjection[],
|
||||
guards: Map<string, ResultDbIdentityGuard>,
|
||||
sourceRevision: number,
|
||||
materializedAt: string
|
||||
): MaterializedResultDb {
|
||||
const rows = sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId)));
|
||||
return {
|
||||
rows,
|
||||
status: {
|
||||
state: 'current',
|
||||
sourceRevision,
|
||||
repositoryRevision: sourceRevision,
|
||||
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
|
||||
identityGuardVersion: RESULT_DB_IDENTITY_GUARD_VERSION,
|
||||
materializedAt,
|
||||
rowCount: rows.length,
|
||||
redactionCount: rows.reduce((sum, row) => sum
|
||||
+ (row.disclosureTrace?.redactions.reduce((total, entry) => total + entry.count, 0) ?? 0), 0),
|
||||
guardWithheldRowCount: rows.filter(row => row.disclosureTrace?.guardWithheldFields.length).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** A stored state is current only for the same repository revision and the same policy versions. */
|
||||
export function isResultDbCurrent(status: Pick<ResultDbMaterializationStatus, 'sourceRevision' | 'disclosurePolicyVersion' | 'identityGuardVersion'> | undefined, repositoryRevision: number): boolean {
|
||||
return status?.sourceRevision === repositoryRevision
|
||||
&& status.disclosurePolicyVersion === RESULT_DB_DISCLOSURE_POLICY_VERSION
|
||||
&& status.identityGuardVersion === RESULT_DB_IDENTITY_GUARD_VERSION;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { JsonValue } from './canonical-model';
|
||||
import type { 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 =
|
||||
| '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(
|
||||
pingCastleRules.map(rule => [`${rule.toolId}\u0000${rule.property}`, rule])
|
||||
);
|
||||
|
|
@ -116,7 +124,7 @@ export function discloseSupplementalObservation(
|
|||
): ResultDbSupplementalDisclosure | undefined {
|
||||
const toolId = observation.tool.id.trim().toLocaleLowerCase('en-US');
|
||||
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);
|
||||
if (disclosedValue === undefined) return undefined;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Content-free redaction trace of one disclosed Result DB row (user decision 2026-09-28).
|
||||
* It records which rule changed which field how often, and which fields were withheld; it never
|
||||
* contains an original value, so it is disclosed, shown and exported together with the row.
|
||||
*/
|
||||
export interface ResultDbDisclosureTrace {
|
||||
version: 1;
|
||||
disclosurePolicyVersion: string;
|
||||
identityGuardVersion: string;
|
||||
/** Replacements per field and rule, for example `description` / `count` / 3. */
|
||||
redactions: Array<{ field: string; rule: string; count: number }>;
|
||||
/** Internal fields that held a value but are never part of the Result DB (for example `policyActualValue`). */
|
||||
withheldFields: string[];
|
||||
/** Fields the identity guard withheld completely because a client identity survived in another form. */
|
||||
guardWithheldFields: string[];
|
||||
}
|
||||
|
||||
const placeholderRules: Array<[RegExp, string]> = [
|
||||
[/\[count\]/g, 'count'], [/\[duration\]/g, 'duration'], [/\[date\]/g, 'date'], [/\[time\]/g, 'time'],
|
||||
[/\[email\]/g, 'email'], [/\[sid\]/g, 'sid'], [/\[id\]|\[ID\]/g, 'identifier'],
|
||||
[/\[directory-object\]/g, 'directory-object'], [/\[account\]/g, 'account'], [/\[network-path\]/g, 'network-path'],
|
||||
[/\[path\]|\[PATH\]/g, 'path'], [/\[url\]/g, 'url'], [/\[ip\]/g, 'ip-address'], [/\[mac\]/g, 'mac-address'],
|
||||
[/\[domain\]|\[DOMAIN\]/g, 'domain'], [/\[organization\]/g, 'organization'], [/\[redacted\]/g, 'private-value'],
|
||||
[/\[(?:DOMAIN|SYSTEM)_PRINCIPAL_\d+\]/g, 'principal'], [/\[withheld by identity guard\]/g, 'identity-guard-withheld']
|
||||
];
|
||||
|
||||
function serialized(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function placeholderCounts(value: unknown): Map<string, number> {
|
||||
const text = serialized(value);
|
||||
const counts = new Map<string, number>();
|
||||
for (const [pattern, rule] of placeholderRules) {
|
||||
const count = text.match(pattern)?.length ?? 0;
|
||||
if (count) counts.set(rule, count);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the placeholders a step added to one field. `before` is the field before the step, so
|
||||
* placeholders that already existed (for example in the source text) are not attributed to it.
|
||||
*/
|
||||
export function addedPlaceholders(before: unknown, after: unknown, renameRule?: (rule: string) => string): Array<{ rule: string; count: number }> {
|
||||
const previous = placeholderCounts(before);
|
||||
return [...placeholderCounts(after)].flatMap(([rule, count]) => {
|
||||
const added = count - (previous.get(rule) ?? 0);
|
||||
return added > 0 ? [{ rule: renameRule ? renameRule(rule) : rule, count: added }] : [];
|
||||
});
|
||||
}
|
||||
|
|
@ -227,6 +227,12 @@ export interface ToolProfileDefinition {
|
|||
firstRead?: {
|
||||
/** Concrete result statement of the scanner for this environment. */
|
||||
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. */
|
||||
affectedHeaders?: RegExp[];
|
||||
/** Count linked result-table rows when no explicit count exists. */
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
discloseSupplementalObservation,
|
||||
type ResultDbSupplementalDisclosureClass
|
||||
} 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 UnifiedResultAudience = 'result-db' | 'internal-analysis';
|
||||
|
|
@ -174,7 +175,8 @@ export interface UnifiedResultProjection {
|
|||
scopeState?: 'known' | 'unknown';
|
||||
effectivenessVerification?: 'verified' | 'not-verified';
|
||||
scopeConfidence?: number;
|
||||
reviewStatus: UnifiedReviewStatus;
|
||||
/** Client-owned review state; internal analysis only, withheld by the Result DB disclosure policy. */
|
||||
reviewStatus?: UnifiedReviewStatus;
|
||||
reviewDisposition?: UnifiedReviewDisposition;
|
||||
reviewPriority?: ReviewPriority;
|
||||
reviewOwner?: string;
|
||||
|
|
@ -185,6 +187,8 @@ export interface UnifiedResultProjection {
|
|||
createdAt: string;
|
||||
categories: UnifiedResultCategory[];
|
||||
frameworkReferences: UnifiedResultFrameworkReference[];
|
||||
/** Result DB only: content-free redaction trace stored with the materialized row. */
|
||||
disclosureTrace?: ResultDbDisclosureTrace;
|
||||
}
|
||||
|
||||
export interface UnifiedResultQuery {
|
||||
|
|
@ -1154,8 +1158,6 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer
|
|||
facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'),
|
||||
facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'),
|
||||
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('effectiveState', 'Effectiveness', item => item.effectiveState),
|
||||
categoryFacet()
|
||||
|
|
|
|||
|
|
@ -6,9 +6,21 @@ import type {
|
|||
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_SCHEMA_VERSION = '2.0.0';
|
||||
export const RESULT_DB_JSONL_SCHEMA_VERSION = '3.0.0';
|
||||
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>;
|
||||
|
||||
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> {
|
||||
const excluded = new Set([
|
||||
'projectId', 'assessmentId', 'assessmentIds', 'scanRunId', 'scanRunIds', 'evaluationRunId',
|
||||
'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo'
|
||||
'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo', 'reviewDisposition'
|
||||
]);
|
||||
return Object.fromEntries(definedQueryEntries(query)
|
||||
.filter(([key]) => !excluded.has(key))
|
||||
|
|
@ -59,7 +71,8 @@ export function buildUnifiedResultJsonl(
|
|||
format: RESULT_DB_JSONL_FORMAT,
|
||||
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||
disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION,
|
||||
generatedAt,
|
||||
handling: RESULT_DB_EXPORT_HANDLING,
|
||||
generatedOn: exportDate(generatedAt),
|
||||
resultCount: results.length,
|
||||
scope: exportScope(query),
|
||||
filters: exportFilters(query)
|
||||
|
|
@ -71,9 +84,13 @@ export function buildUnifiedResultJsonl(
|
|||
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 {
|
||||
const timestamp = generatedAt.replace(/\.\d{3}Z$/, 'Z').replaceAll(':', '-');
|
||||
return `assessor-result-db-filtered-${timestamp}.jsonl`;
|
||||
return `assessor-result-db-filtered-${exportDate(generatedAt)}.jsonl`;
|
||||
}
|
||||
|
||||
export async function queryAllUnifiedResults(
|
||||
|
|
|
|||
|
|
@ -158,6 +158,32 @@ export function powerShellDisplayLines(command: string): string[] {
|
|||
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(
|
||||
record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome
|
||||
): FindingFirstRead | undefined {
|
||||
|
|
@ -166,6 +192,15 @@ export function findingFirstRead(
|
|||
const rows: FirstReadRow[] = [];
|
||||
const result = fieldFor(record, definition.resultHeaders);
|
||||
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);
|
||||
if (definition.criterionEvidence) {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { browserDownloadAdapter } from '../../platform/browser/browser-download-
|
|||
import { downloadText } from '../../exports/text-download';
|
||||
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
|
||||
import { hasSelectedTimeslotScope } from '../../app/work-scope';
|
||||
import { baselineVariantSummary } from '../../evaluation/policy-baseline-variants';
|
||||
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
|
||||
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
||||
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
|
||||
import type { ResultDbMaterializationStatus } from '../../domain/result-db-materialization';
|
||||
|
||||
const assessorApi = new AssessorApiClient();
|
||||
const PAGE_SIZE = 50;
|
||||
|
|
@ -24,12 +24,16 @@ interface ResultsBrowserProps {
|
|||
projectId?: string;
|
||||
scanRunIds?: 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 [limit, setLimit] = useState(PAGE_SIZE);
|
||||
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
|
||||
const [resultDbStatus, setResultDbStatus] = useState<ResultDbMaterializationStatus | null>(null);
|
||||
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
|
@ -59,6 +63,8 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
|||
]);
|
||||
setFacetResult(facets);
|
||||
setQueryResult(results);
|
||||
// Read after the rows: serving them guarantees the stored Result DB is current.
|
||||
setResultDbStatus(await assessorApi.getResultDbStatus().catch(() => null));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Results could not be loaded.');
|
||||
} 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>
|
||||
</div>}
|
||||
<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">
|
||||
<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}
|
||||
|
|
@ -137,9 +143,10 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
|||
</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">
|
||||
<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>
|
||||
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
|
||||
workRecordIds={workRecordIds?.(item) ?? []} onOpenInWork={onOpenInWork}
|
||||
expanded={expandedId === item.resultProjectionId}
|
||||
onToggle={() => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)}
|
||||
</div>}
|
||||
|
|
@ -149,7 +156,13 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
|
|||
</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 environment = item.sourceKind === 'environment-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="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
|
||||
<span data-label="Tool">{resultSourceToolLabel(item.sourceTool)}</span>
|
||||
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span>
|
||||
<span data-label="Review">{environment ? '–' : item.reviewStatus}</span>
|
||||
</button>
|
||||
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span> </button>
|
||||
{expanded && <div className="results-browser-row-detail">
|
||||
{environment && <div className="results-environment-value">
|
||||
<strong>{item.environmentProperty}</strong>
|
||||
|
|
@ -181,8 +192,6 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
|
|||
{item.description && <p>{item.description}</p>}
|
||||
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
|
||||
{policyComparison && <div className="results-environment-value">
|
||||
<strong>Configured customer value</strong>
|
||||
<p>{displayPolicyValue(item.policyActualValue)}</p>
|
||||
<strong>Expected baseline value</strong>
|
||||
<p>{displayPolicyValue(item.policyBaselineValue)}</p>
|
||||
</div>}
|
||||
|
|
@ -207,10 +216,10 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
|
|||
<li>Baseline: {item.policyBaselineLabel}</li>
|
||||
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</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>
|
||||
<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">Original baseline value: {displayPolicyValue(variant.baselineValues)}</p>
|
||||
</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>
|
||||
</ul>
|
||||
<DisclosureTraceDetail item={item} workRecordIds={workRecordIds} onOpenInWork={onOpenInWork}/>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -239,6 +249,14 @@ function displayPolicyValue(value: unknown): string {
|
|||
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 {
|
||||
const identity = item.policyIdentityKey ?? item.sourceRuleId;
|
||||
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';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const traceFieldLabels: Record<string, string> = {
|
||||
title: 'Title', description: 'Description', recommendation: 'Recommendation', sourceRuleId: 'Source Rule ID',
|
||||
policyBaselineLabel: 'Baseline', policyBaselineValue: 'Expected baseline value',
|
||||
policyBaselineVariantAssessment: 'Baseline expectations', environmentNormalizedValue: 'Value', categories: 'Categories'
|
||||
};
|
||||
|
||||
function resultDbStatusText(status: ResultDbMaterializationStatus): string {
|
||||
if (status.state === 'not-built') return 'Result DB not built yet';
|
||||
const checked = status.materializedAt ? status.materializedAt.slice(0, 16).replace('T', ' ') : '–';
|
||||
return [
|
||||
status.state === 'current' ? `Checked ${checked} UTC` : `Outdated: checked ${checked} UTC, rebuilding`,
|
||||
`policy ${status.disclosurePolicyVersion ?? '–'}`,
|
||||
`identity guard ${status.identityGuardVersion ?? '–'}`,
|
||||
`${status.rowCount.toLocaleString('en-US')} rows`,
|
||||
`${status.redactionCount.toLocaleString('en-US')} redactions`,
|
||||
`${status.guardWithheldRowCount.toLocaleString('en-US')} rows with withheld fields`
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/** Content-free trace of the stored row: what was redacted and withheld, never the original. */
|
||||
function DisclosureTraceDetail({ item, workRecordIds, onOpenInWork }: {
|
||||
item: UnifiedResultProjection;
|
||||
workRecordIds: string[];
|
||||
onOpenInWork?(recordIds: string[]): void;
|
||||
}) {
|
||||
const trace = item.disclosureTrace;
|
||||
if (!trace) return null;
|
||||
const byField = new Map<string, string[]>();
|
||||
for (const entry of trace.redactions) {
|
||||
byField.set(entry.field, [...(byField.get(entry.field) ?? []), `${entry.rule} × ${entry.count.toLocaleString('en-US')}`]);
|
||||
}
|
||||
const hasOriginal = item.sourceKind === 'scanner-finding' || item.sourceKind === 'policy-evaluation';
|
||||
return <div className="results-disclosure-trace">
|
||||
<strong>Checked content</strong>
|
||||
<ul>
|
||||
<li>Policy {trace.disclosurePolicyVersion} · identity guard {trace.identityGuardVersion}</li>
|
||||
{[...byField].map(([field, rules]) => <li key={field}>Redacted in {traceFieldLabels[field] ?? field}: {rules.join(', ')}</li>)}
|
||||
{!byField.size && <li>No redaction in the disclosed fields</li>}
|
||||
{trace.withheldFields.length > 0 && <li>Withheld fields: {trace.withheldFields.join(', ')}</li>}
|
||||
{trace.guardWithheldFields.length > 0 && <li>Withheld by identity guard: {trace.guardWithheldFields.map(field => traceFieldLabels[field] ?? field).join(', ')}</li>}
|
||||
</ul>
|
||||
{hasOriginal && (workRecordIds.length && onOpenInWork
|
||||
? <button className="btn" type="button" onClick={() => onOpenInWork(workRecordIds)}>Open original in Work</button>
|
||||
: <p>The original is not part of the loaded Work scope.</p>)}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface ResultsFilterPanelProps {
|
|||
}
|
||||
|
||||
type MultiSelectFacetId = 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState'
|
||||
| 'resultType' | 'processingStatus' | 'reviewDisposition' | 'lifecycleState'
|
||||
| 'resultType' | 'processingStatus' | 'lifecycleState'
|
||||
| 'environmentState' | 'environmentProperty' | 'environmentSubjectType' | 'policyClassification';
|
||||
|
||||
const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> = {
|
||||
|
|
@ -19,7 +19,6 @@ const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> =
|
|||
technicalExecutionState: 'technicalExecutionState',
|
||||
resultType: 'resultType',
|
||||
processingStatus: 'processingStatus',
|
||||
reviewDisposition: 'reviewDisposition',
|
||||
lifecycleState: 'lifecycleState',
|
||||
environmentState: 'environmentState',
|
||||
environmentProperty: 'environmentProperty',
|
||||
|
|
@ -27,7 +26,7 @@ const QUERY_KEY_BY_FACET: Record<MultiSelectFacetId, keyof UnifiedResultQuery> =
|
|||
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 {
|
||||
const set = new Set(current);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { ImportedDataset } from '../../domain/dataset';
|
||||
import type { UnifiedResultProjection } from '../../domain/unified-result';
|
||||
|
||||
/**
|
||||
* Work records holding the original of a Result DB row (user decision 2026-09-28: the Result DB shows
|
||||
* only the checked content; the original stays in Work). Scanner rows match their finding occurrence;
|
||||
* GPO comparison rows match the Work setting of the same scope and technical setting identity.
|
||||
*/
|
||||
export function workRecordIdsForResult(dataset: ImportedDataset | null | undefined, row: UnifiedResultProjection): string[] {
|
||||
if (!dataset) return [];
|
||||
const ids = new Set<string>();
|
||||
for (const sheet of dataset.sheets) {
|
||||
for (const record of sheet.analysis.records) {
|
||||
if (row.sourceKind === 'scanner-finding' && record.findingOccurrenceId === row.sourceResultId) ids.add(record.id);
|
||||
if (row.sourceKind === 'policy-evaluation' && row.sourceRuleId && record.id.startsWith('gpo-work-setting:')) {
|
||||
try {
|
||||
const key = JSON.parse(decodeURIComponent(record.id.slice('gpo-work-setting:'.length))) as unknown[];
|
||||
if (key[2] === row.policyScope && key[3] === row.sourceRuleId) ids.add(record.id);
|
||||
} catch {
|
||||
// Not an evaluated setting key; unevaluated diagnostics have no comparison original.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from '../domain/canonical-model';
|
||||
import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
|
||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||
import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison';
|
||||
import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow';
|
||||
import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence';
|
||||
|
|
@ -210,6 +211,8 @@ export interface AssessorRepository {
|
|||
queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
|
||||
queryUnifiedResultFacets(query?: UnifiedResultQuery): Promise<UnifiedResultFacetResult>;
|
||||
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[]>;
|
||||
getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise<EnvironmentSnapshot>;
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ import {
|
|||
type UnifiedResultQueryResult
|
||||
} from '../domain/unified-result';
|
||||
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 type {
|
||||
AssessorRepository,
|
||||
|
|
@ -294,6 +296,7 @@ class LocalImportSession implements ImportSession {
|
|||
export class LocalAssessorRepository implements AssessorRepository {
|
||||
private state = emptyState();
|
||||
private stateRevision = 0;
|
||||
private resultDb?: MaterializedResultDb;
|
||||
private readonly activeImportSessions = new Set<string>();
|
||||
|
||||
get revision(): number {
|
||||
|
|
@ -647,7 +650,42 @@ export class LocalAssessorRepository implements AssessorRepository {
|
|||
? createPolicyConsistencyUnifiedResults(currentPolicyObservations)
|
||||
: [];
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -54,9 +54,11 @@ export const maesterDefinition: ToolProfileDefinition = {
|
|||
],
|
||||
normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
|
||||
presentation: {
|
||||
// The native test result stays in Results only (user decision 2026-09-25); the
|
||||
// first read counts the rows Maester itself marks and links its documentation.
|
||||
// User decision 2026-09-28 supersedes the Results-only rule of 2026-09-25 for the lead: the first
|
||||
// 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: {
|
||||
resultLeadHeaders: [/^resultdetail$/i, /^test result$/i, /^result$/i],
|
||||
statusRowCounts: true,
|
||||
nextStepLinkHeaders: [/^helpurl$/i],
|
||||
// Maester descriptions carry their remediation as a native `#### Remediation action:` subsection.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"passwords": "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpsb/0b40db09-d95d-40a6-8467-32aedec8140c",
|
||||
"asr": "https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-overview",
|
||||
"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": [
|
||||
"SOFTWARE\\Policies\\Microsoft\\Windows Defender\\PUAProtection",
|
||||
|
|
@ -757,5 +758,15 @@
|
|||
"a8f5898e-1dc8-49a9-9878-85004b8a61e6",
|
||||
"92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b",
|
||||
"c1db55ab-c21a-4637-bb3f-a12568109d35"
|
||||
],
|
||||
"microsoftIdentifiers": [
|
||||
"499b84ac-1321-427f-aa17-267ca6975798",
|
||||
"797f4846-ba00-4fd7-ba43-dac1f8f63013",
|
||||
"14d82eec-204b-4c2f-b7e8-296a70dab67e",
|
||||
"de8bc8b5-d9f9-48b1-a8ad-b748da725064",
|
||||
"62e90394-69f5-4237-9190-012177145e10",
|
||||
"a0b1b346-4d3e-4e8b-98f8-753987be4970",
|
||||
"10dae51f-b6af-4016-8d66-8c2a99b929b3",
|
||||
"2af84b1e-32c8-42b7-82bc-daa82404023b"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,10 +367,13 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
|
|||
.client-info-card-details { display: grid; align-content: start; gap: 0; min-width: 0; padding: 0 var(--space-5) var(--space-3); }
|
||||
.client-info-card-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. */
|
||||
.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; }
|
||||
/* Keep the six overview tiles in one row; narrower panels scroll horizontally. */
|
||||
.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-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-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; }
|
||||
|
|
@ -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.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 dd { overflow-wrap: normal; word-break: normal; }
|
||||
.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-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-row {
|
||||
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;
|
||||
}
|
||||
.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-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-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 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; }
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
Beschriftung liegen gemeinsam in derselben klick- und fokussierbaren Aktion. */
|
||||
.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; }
|
||||
.results-browser-title { display: flex; align-items: center; gap: 7px; }
|
||||
.results-browser-title { display: flex; align-items: center; gap: 7px; text-align: left; }
|
||||
|
||||
summary {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
|||
|
||||
describe('ASSESSOR version in the brand header', () => {
|
||||
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('aria-label="About ASSESSOR"');
|
||||
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
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 {
|
||||
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', () => {
|
||||
const disclosed = discloseResultDbProjection({
|
||||
...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/);
|
||||
});
|
||||
|
||||
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 disclosed = discloseResultDbProjection({
|
||||
...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',
|
||||
principals: ['[SYSTEM_PRINCIPAL_1]', 'S-1-5-32-544'] });
|
||||
expect(disclosed).not.toHaveProperty('policyActualValue');
|
||||
expect(disclosed.policyBaselineValue).toMatchObject({ kind: 'text', value: '10' });
|
||||
expect(JSON.stringify(disclosed)).not.toContain(systemSid);
|
||||
expect(JSON.stringify(disclosed).match(/\[SYSTEM_PRINCIPAL_1\]/g)).toHaveLength(3);
|
||||
expect(disclosed.policyBaselineVariantAssessment).toMatchObject({ decision: 'all-fail', actualValues: [],
|
||||
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({
|
||||
...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
|
||||
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({
|
||||
title: 'Restrict driver installation to administrators', sourceTool: 'GPOs',
|
||||
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 } },
|
||||
policyBaselineLabel: 'Microsoft Security Baseline Windows Server 2022 v1',
|
||||
policyBaselineLabel: 'Microsoft Security Baseline - Windows Server',
|
||||
policyComparisonRelation: 'different',
|
||||
recommendation: 'Set RestrictDriverInstallationToAdministrators to 1.',
|
||||
policyDiagnosticCodes: ['policy-safe-code'],
|
||||
policyBaselineVariantAssessment: {
|
||||
variants: [{ settingId: 'baseline-variant-1', comparator: {
|
||||
actual: { kind: 'string-set', values: ['[DOMAIN_PRINCIPAL_2]'] },
|
||||
baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] }
|
||||
actualValues: [],
|
||||
variants: [{ settingId: 'baseline-expectation-1', comparator: {
|
||||
relation: 'different', baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] }
|
||||
}}]
|
||||
}
|
||||
});
|
||||
expect(disclosed).not.toHaveProperty('policyActualValue');
|
||||
expect(disclosed.policyIdentityKey).toBeUndefined();
|
||||
expect(disclosed.policyBaselineId).toBeUndefined();
|
||||
const serialized = JSON.stringify(disclosed);
|
||||
for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY – Domain Controllers Security',
|
||||
'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', '123456789', '234567890', '345678901']) {
|
||||
for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY', 'Domain Admins', 'PRINCIPAL', '2022',
|
||||
'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', 'named.user', '123456789', '234567890', '345678901']) {
|
||||
expect(serialized).not.toContain(privateValue);
|
||||
}
|
||||
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'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the complete seven-level CIS number so sibling recommendations do not collide', () => {
|
||||
const first = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '18.10.42.11.1.1.1' });
|
||||
const second = discloseResultDbProjection({
|
||||
...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: 'xccdf_org.cisecurity.benchmarks_rule_18.10.42.11.1.1.2_L1_Ensure'
|
||||
});
|
||||
expect(first.sourceRuleId).toBe('18.10.42.11.1.1.1');
|
||||
expect(second.sourceRuleId).toBe('18.10.42.11.1.1.2');
|
||||
expect(discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '1.2.3.4.5.6.7.8.9.10' }).sourceRuleId)
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mistake the ordinary word "limited" for an organization but still redacts legal names', () => {
|
||||
const disclosed = discloseResultDbProjection({
|
||||
...unsafeScanner(), title: 'Limited number of Global Admins are assigned.',
|
||||
description: 'External sharing limited to existing guests.', recommendation: 'Contact Contoso Ltd or Fabrikam GmbH.'
|
||||
});
|
||||
expect(disclosed.title).toBe('Limited number of Global Admins are assigned.');
|
||||
expect(disclosed.description).toBe('External sharing limited to existing guests.');
|
||||
expect(disclosed.recommendation).not.toMatch(/Contoso|Fabrikam/);
|
||||
expect(disclosed.recommendation).toContain('[organization]');
|
||||
});
|
||||
|
||||
it('keeps public versions, releases and step, option and page references in scanner prose', () => {
|
||||
const disclosed = discloseResultDbProjection({
|
||||
...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'A-DCLdapsProtocolAdvanced',
|
||||
title: 'At least one DC is using Tls1.0 or Tls1.1.',
|
||||
description: 'NTLMv2 works starting Windows 95. See Microsoft Graph v1.0. If using a pre 2012-R2 schema, '
|
||||
+ 'divide the value from step 2 by the value from step 3. Option 1 is described on Page 16. 4 DCs were observed.'
|
||||
});
|
||||
expect(disclosed.title).toBe('At least one DC is using Tls1.0 or Tls1.1.');
|
||||
expect(disclosed.description).toBe('NTLMv2 works starting Windows 95. See Microsoft Graph v1.0. If using a pre 2012-R2 schema, '
|
||||
+ 'divide the value from step 2 by the value from step 3. Option 1 is described on Page 16. [count] DCs were observed.');
|
||||
});
|
||||
|
||||
it('keeps documented Microsoft GUIDs but redacts every other GUID', () => {
|
||||
const disclosed = discloseResultDbProjection({
|
||||
...unsafeScanner(),
|
||||
description: 'Add Azure DevOps (App ID: 499b84ac-1321-427f-aa17-267ca6975798) and block with ASR rule '
|
||||
+ 'd3e037e1-3eb8-44c8-a917-57927947596d. Tenant 11111111-2222-4333-8444-555555555555 is affected.'
|
||||
});
|
||||
expect(disclosed.description).toBe('Add Azure DevOps (App ID: 499b84ac-1321-427f-aa17-267ca6975798) and block with ASR rule '
|
||||
+ 'd3e037e1-3eb8-44c8-a917-57927947596d. Tenant [id] is affected.');
|
||||
});
|
||||
|
||||
it('withholds per-finding scanner scores', () => {
|
||||
const disclosed = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000011', score: 73 });
|
||||
expect(disclosed).not.toHaveProperty('score');
|
||||
});
|
||||
|
||||
it('lists withheld fields in the trace without revealing client review state', () => {
|
||||
const reviewed = discloseResultDbProjectionWithTrace({ ...unsafeScanner(), reviewDisposition: 'accepted', reviewPriority: 'high' });
|
||||
const unreviewed = discloseResultDbProjectionWithTrace({
|
||||
...unsafeScanner(), reviewStatus: undefined, reviewOwner: undefined, reviewDueDate: undefined
|
||||
});
|
||||
expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields);
|
||||
expect(JSON.stringify(reviewed.disclosureTrace)).not.toMatch(/review/i);
|
||||
expect(reviewed.disclosureTrace?.withheldFields).toContain('affectedAsset');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { AssessmentProject } from '../../src/domain/canonical-model';
|
||||
import type { EnvironmentObservation } from '../../src/domain/environment-context';
|
||||
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||
import {
|
||||
buildResultDbIdentityGuards,
|
||||
collectResultDbIdentityLiterals,
|
||||
ResultDbIdentityGuard
|
||||
} from '../../src/domain/result-db-identity-guard';
|
||||
import { discloseResultDbProjection, discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure';
|
||||
|
||||
const project: AssessmentProject = {
|
||||
id: 'synthetic-project', name: 'Westeros Holding', createdAt: '2026-09-28T00:00:00Z', updatedAt: '2026-09-28T00:00:00Z',
|
||||
attributes: { shortName: 'WSTR' }
|
||||
};
|
||||
|
||||
function observation(property: string, value: string, identity?: string): EnvironmentObservation {
|
||||
return {
|
||||
version: 1, id: `obs-${property}`, scanRunId: 'run', importFileId: 'file',
|
||||
tool: { id: 'pingcastle', name: 'PingCastle' }, subject: { type: 'domain-information', identity },
|
||||
property, label: property, originalValue: value, normalizedValue: value, valueType: 'string', state: 'observed',
|
||||
source: { objectPath: 'synthetic' }, mappingVersion: 'synthetic', attributes: {}
|
||||
};
|
||||
}
|
||||
|
||||
function scanner(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResultProjection {
|
||||
return {
|
||||
resultProjectionId: 'scanner-finding:occ-1', sourceKind: 'scanner-finding', sourceResultId: 'occ-1',
|
||||
projectId: project.id, assessmentId: 'timeslot-7f3a', collectionRunId: 'scan-run-7f3a', scanRunId: 'scan-run-7f3a', sourceTool: 'pingcastle',
|
||||
sourceRuleId: 'A-DC-Spooler', title: 'Spooler is running on DC01', description: 'Host DC01 in sevenkingdoms.local exposes it.',
|
||||
recommendation: 'Disable the spooler on WINTERFELL and ask Jon Snow.', affectedAsset: 'DC01',
|
||||
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
||||
technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true,
|
||||
lifecycleState: 'unknown', mappingConfidence: 1, reviewOwner: 'Jon Snow', importedAt: '2026-09-28T00:00:00Z',
|
||||
createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('Result DB client identity guard', () => {
|
||||
it('collects the identities a client names in its own data, without generic or public words', () => {
|
||||
const literals = collectResultDbIdentityLiterals({
|
||||
projects: [project],
|
||||
environmentObservations: [observation('domain.fqdn', 'sevenkingdoms.local'), observation('host.name', 'WINTERFELL', 'WINTERFELL'),
|
||||
observation('domain.functional_level', 'Windows Server 2016')],
|
||||
affectedAssets: ['DC01', 'Domain Admins', 'krbtgt'],
|
||||
reviewOwners: ['Jon Snow'],
|
||||
principals: ['SEVENKINGDOMS\\arya.stark', 'BUILTIN\\Administrators', 'ned@sevenkingdoms.local']
|
||||
});
|
||||
// Case-insensitive: the NetBIOS name SEVENKINGDOMS and the domain label sevenkingdoms are one literal.
|
||||
expect(literals.map(value => value.toLowerCase())).toEqual(expect.arrayContaining(['westeros holding', 'wstr',
|
||||
'sevenkingdoms.local', 'sevenkingdoms', 'winterfell', 'dc01', 'jon snow', 'arya.stark', 'ned']));
|
||||
expect(literals.map(value => value.toLowerCase())).not.toEqual(expect.arrayContaining(['domain admins', 'krbtgt', 'builtin',
|
||||
'administrators', 'windows server 2016']));
|
||||
expect(literals.indexOf('sevenkingdoms.local')).toBeLessThan(literals.indexOf('sevenkingdoms'));
|
||||
});
|
||||
|
||||
it('removes names that no pattern recognizes, such as DC01 or a person, from every disclosed field', () => {
|
||||
const guards = buildResultDbIdentityGuards({
|
||||
projects: [project],
|
||||
environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }],
|
||||
internalResults: [scanner()]
|
||||
});
|
||||
const disclosed = discloseResultDbProjection(scanner(), guards.get(project.id));
|
||||
const serialized = JSON.stringify(disclosed);
|
||||
for (const identity of ['DC01', 'WINTERFELL', 'Jon Snow', 'sevenkingdoms']) expect(serialized).not.toContain(identity);
|
||||
expect(disclosed.title).toBe('Spooler is running on [redacted]');
|
||||
});
|
||||
|
||||
it('takes only account forms from GPO values, never registry keys or paths', () => {
|
||||
const guards = buildResultDbIdentityGuards({
|
||||
projects: [project], environmentObservations: [],
|
||||
internalResults: [scanner({ policyValues: [{ key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers', valueName: 'Machine\\Test',
|
||||
principals: ['SEVENKINGDOMS\\arya.stark', 'HKLM\\Software', 'BUILTIN\\Administrators'] }] })]
|
||||
});
|
||||
const guard = guards.get(project.id)!;
|
||||
expect(guard.text('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators'))
|
||||
.toBe('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators');
|
||||
expect(guard.text('Granted to arya.stark in SEVENKINGDOMS')).toBe('Granted to [redacted] in [redacted]');
|
||||
});
|
||||
|
||||
it('withholds a whole field when an identity survives in another spelling', () => {
|
||||
const guard = new ResultDbIdentityGuard(['sevenkingdoms.local']);
|
||||
expect(guard.text('Forest seven-kingdoms . local is affected')).toBe('[withheld by identity guard]');
|
||||
expect(guard.text('Forest sevenkingdoms.local is affected')).toBe('Forest [redacted] is affected');
|
||||
expect(guard.text('Nothing identifying here')).toBe('Nothing identifying here');
|
||||
});
|
||||
|
||||
it('records a content-free trace for every stored row', () => {
|
||||
const guards = buildResultDbIdentityGuards({
|
||||
projects: [project], environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }],
|
||||
internalResults: [scanner()]
|
||||
});
|
||||
const row = discloseResultDbProjectionWithTrace(scanner({
|
||||
description: 'Host DC01 has 15 accounts, see https://tenant.sharepoint.com/x.', reviewStatus: 'open'
|
||||
}), guards.get(project.id));
|
||||
expect(row.disclosureTrace).toMatchObject({ version: 1, guardWithheldFields: [] });
|
||||
// DC01 is this finding's own affected asset (private-value); Jon Snow is caught by the client guard.
|
||||
expect(row.disclosureTrace?.redactions).toEqual(expect.arrayContaining([
|
||||
{ field: 'title', rule: 'private-value', count: 1 },
|
||||
{ field: 'description', rule: 'count', count: 1 },
|
||||
{ field: 'description', rule: 'url', count: 1 },
|
||||
{ field: 'description', rule: 'private-value', count: 1 },
|
||||
{ field: 'recommendation', rule: 'private-value', count: 1 },
|
||||
{ field: 'recommendation', rule: 'identity-guard', count: 1 }
|
||||
]));
|
||||
expect(row.disclosureTrace?.withheldFields).toContain('affectedAsset');
|
||||
// Client-owned review state is withheld without a trace entry, so its presence is not revealed.
|
||||
expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/review/i);
|
||||
expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/DC01|WINTERFELL|Jon Snow|15|sharepoint/);
|
||||
expect(row.recommendation).toBe('Disable the spooler on [redacted] and ask [redacted].');
|
||||
});
|
||||
|
||||
it('is idempotent: disclosing a stored row again changes nothing, so UI, API and export show the same content', () => {
|
||||
const guards = buildResultDbIdentityGuards({ projects: [project], environmentObservations: [], internalResults: [scanner()] });
|
||||
const fixtures = [
|
||||
scanner(),
|
||||
scanner({ sourceTool: 'maester', sourceRuleId: 'MT.1074', title: 'No more than 100 mails via the .onmicrosoft.com domain',
|
||||
description: '1. Open [Entra](https://entra.microsoft.com). 2. Select Users. 3. Save. Found 4 accounts.',
|
||||
recommendation: 'Set at least 8 characters; see https://learn.microsoft.com/en-us/entra/ and Directory.ReadWrite.All.' }),
|
||||
scanner({ sourceTool: 'cis-cat', sourceRuleId: '18.9.20.1.1',
|
||||
title: "18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'",
|
||||
recommendation: 'Computer Configuration\\Policies\\Administrative Templates\\System\\Internet Communication Management' })
|
||||
];
|
||||
for (const fixture of fixtures) {
|
||||
const stored = discloseResultDbProjectionWithTrace(fixture, guards.get(project.id));
|
||||
expect(discloseResultDbProjection(stored)).toEqual(stored);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -28,16 +28,19 @@ function observation(overrides: Partial<EnvironmentObservation> = {}): Environme
|
|||
|
||||
describe('Result DB supplemental disclosure boundary', () => {
|
||||
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({
|
||||
policyVersion: RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION,
|
||||
disclosureClass: 'technical-metadata',
|
||||
property: 'domain.functional_level',
|
||||
title: 'Domain functional level',
|
||||
disclosureClass: 'configuration-state',
|
||||
property: 'domain.recycle_bin_enabled',
|
||||
title: 'AD Recycle Bin enabled',
|
||||
subjectType: 'active-directory-environment',
|
||||
value: 2016,
|
||||
valueType: 'number',
|
||||
value: true,
|
||||
valueType: 'boolean',
|
||||
state: 'observed'
|
||||
});
|
||||
expect(JSON.stringify(disclosed)).not.toMatch(/Private Customer|private\.synthetic|customer\/path/i);
|
||||
|
|
@ -58,10 +61,15 @@ describe('Result DB supplemental disclosure boundary', () => {
|
|||
}))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows bounded aggregate and boolean configuration values only', () => {
|
||||
expect(discloseSupplementalObservation(observation({
|
||||
property: 'risk.domain', normalizedValue: 42, originalValue: 'Private score 42'
|
||||
}))).toMatchObject({ value: 42, disclosureClass: 'aggregate' });
|
||||
it('withholds counts, scores, levels and versions that fingerprint the environment', () => {
|
||||
for (const [property, value] of [['risk.domain', 42], ['domain.controller_count', 1], ['domain.functional_level', 2016],
|
||||
['forest.schema_version', 88], ['kerberos.krbtgt.version', 3], ['report.engine_version', '3.3.0.0'],
|
||||
['logging.wef.configuration_count', 0]] as const) {
|
||||
expect(discloseSupplementalObservation(observation({ property, normalizedValue: value }))).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows bounded boolean configuration values only', () => {
|
||||
expect(discloseSupplementalObservation(observation({
|
||||
property: 'domain.recycle_bin_enabled', normalizedValue: true, originalValue: 'Private enabled', valueType: 'boolean'
|
||||
}))).toMatchObject({ value: true, disclosureClass: 'configuration-state' });
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|||
import {
|
||||
buildUnifiedResultJsonl,
|
||||
queryAllUnifiedResults,
|
||||
RESULT_DB_EXPORT_HANDLING,
|
||||
RESULT_DB_JSONL_FORMAT,
|
||||
RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||
unifiedResultJsonlFileName
|
||||
|
|
@ -48,8 +49,9 @@ describe('filtered Result DB JSONL export', () => {
|
|||
recordType: 'manifest',
|
||||
format: RESULT_DB_JSONL_FORMAT,
|
||||
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||
disclosurePolicyVersion: '2.5.3',
|
||||
generatedAt: '2026-09-03T12:34:56.789Z',
|
||||
disclosurePolicyVersion: '3.4.0',
|
||||
handling: RESULT_DB_EXPORT_HANDLING,
|
||||
generatedOn: '2026-09-03',
|
||||
resultCount: 2,
|
||||
scope: { projectScoped: true, assessmentScopeCount: 0, scanScopeCount: 1, evaluationScoped: false },
|
||||
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.endsWith('\n')).toBe(true);
|
||||
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 () => {
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
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 = {
|
||||
...result('policy-result'), sourceKind: 'policy-evaluation' as const, sourceTool: 'GPOs',
|
||||
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');
|
||||
expect(text).toContain('Synthetic policy mode');
|
||||
expect(text).toContain('registry:machine:software');
|
||||
expect(text).toContain('policyActualValue');
|
||||
expect(text).toContain('policyBaselineValue');
|
||||
expect(text).toContain('BUILTIN\\\\Administrators');
|
||||
expect(text).toContain('[DOMAIN]\\\\Domain Admins');
|
||||
expect(text).toContain('[DOMAIN_PRINCIPAL_1]');
|
||||
expect(text).toContain('S-1-5-21-[DOMAIN]-512');
|
||||
expect(text).toContain('"policyBaselineLabel":"Microsoft Security Baseline - Windows Server"');
|
||||
expect(text).not.toContain('policyActualValue');
|
||||
expect(text).not.toMatch(/Administrators|Domain Admins|PRINCIPAL|S-1-5-21|2022/);
|
||||
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 cis = { ...result('cis'), sourceTool: 'cis-cat', sourceRuleId: '18.10.36.1',
|
||||
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');
|
||||
expect(exported).toContain('18.10.36.1');
|
||||
expect(exported).toContain('Computer Configuration\\\\Administrative Templates');
|
||||
expect(exported).toContain('"value":"5"');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ describe('client info environment detail', () => {
|
|||
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).not.toContain('className="client-environment-detail"');
|
||||
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).toContain('icon={<DashboardGlyph name="computer"/>}');
|
||||
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);
|
||||
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);
|
||||
const overviewLayout = styles.match(/\.client-environment-grid\s*\{([^}]*)\}/)?.[1] ?? '';
|
||||
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-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);
|
||||
|
|
|
|||
|
|
@ -524,7 +524,8 @@ describe('selectFindingFields', () => {
|
|||
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('Extension name');
|
||||
expect(reader).toContain('CIS.M365.2.1.11');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||
import { deriveUnifiedResultFacets, queryUnifiedResultItems, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||
import { ResultRow, ResultsBrowser } from '../../src/features/results-browser/ResultsBrowser';
|
||||
import { discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure';
|
||||
import { ResultDbIdentityGuard } from '../../src/domain/result-db-identity-guard';
|
||||
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
|
||||
|
||||
function reviewedFinding(id: string): UnifiedResultProjection {
|
||||
return {
|
||||
resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceKind: 'scanner-finding',
|
||||
sourceTool: 'purple-knight', sourceRuleId: 'SI000055', projectId: 'synthetic', assessmentId: 'slot',
|
||||
collectionRunId: 'collection', scanRunId: 'scan', title: `Synthetic delegation finding ${id}`,
|
||||
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
||||
technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true,
|
||||
lifecycleState: 'unknown', mappingConfidence: 1,
|
||||
reviewStatus: 'open', reviewDisposition: 'accepted', reviewPriority: 'critical',
|
||||
reviewOwner: 'Synthetic Owner', reviewDueDate: '2026-10-01',
|
||||
importedAt: '2026-09-28T00:00:00Z', createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: []
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('Result DB export disclosure', () => {
|
||||
it('shows no review column or review filter and exports JSONL without review state or environment fingerprints', async () => {
|
||||
const items = [reviewedFinding('a'), {
|
||||
...reviewedFinding('b'), sourceTool: 'pingcastle', sourceRuleId: 'A-NotEnoughDC',
|
||||
title: 'The number of DCs is too small to provide redundancy: 1 DC',
|
||||
description: 'Last backup 55 days ago; 15 accounts never expire.'
|
||||
}];
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets')
|
||||
.mockImplementation(async query => deriveUnifiedResultFacets(items, query));
|
||||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults')
|
||||
.mockImplementation(async query => queryUnifiedResultItems(items, query));
|
||||
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||||
expect(container.querySelectorAll('.results-browser-title')).toHaveLength(2);
|
||||
const headings = [...container.querySelectorAll('.results-browser-head span')].map(item => item.textContent);
|
||||
expect(headings).not.toContain('Review');
|
||||
expect(container.querySelector('[data-label="Review"]')).toBeNull();
|
||||
const filterHeadings = [...container.querySelectorAll('section.results-filter-group h3')].map(item => item.textContent);
|
||||
expect(filterHeadings.some(heading => /review/i.test(heading ?? ''))).toBe(false);
|
||||
|
||||
const exportButton = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Export JSONL')!;
|
||||
await act(async () => exportButton.click());
|
||||
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
const text = download.mock.calls[0][0].text;
|
||||
const [manifest, ...results] = text.trimEnd().split('\n').map(line => JSON.parse(line));
|
||||
expect(manifest.handling.classification).toBe('confidential-security-posture');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map(line => line.result.sourceRuleId).sort()).toEqual(['A-NotEnoughDC', 'SI000055']);
|
||||
expect(text).not.toMatch(/reviewStatus|reviewDisposition|reviewPriority|reviewOwner|reviewDueDate|Synthetic Owner|accepted/);
|
||||
const dc = results.find(line => line.result.sourceRuleId === 'A-NotEnoughDC')!.result;
|
||||
expect(dc.title).toBe('The number of DCs is too small to provide redundancy: [count] DC');
|
||||
expect(dc.description).toBe('Last backup [duration] ago; [count] accounts never expire.');
|
||||
expect(manifest).not.toHaveProperty('generatedAt');
|
||||
expect(download.mock.calls[0][0].fileName).toMatch(/^assessor-result-db-filtered-\d{4}-\d{2}-\d{2}\.jsonl$/);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it('never reveals that a stored row had client review state, neither in the row detail nor in the JSONL export', async () => {
|
||||
// The backend serves materialized rows that carry their trace, so this is the real export input.
|
||||
const reviewed = discloseResultDbProjectionWithTrace({ ...reviewedFinding('first'), score: 27 });
|
||||
const unreviewed = discloseResultDbProjectionWithTrace({
|
||||
...reviewedFinding('second'), reviewStatus: undefined, reviewDisposition: undefined,
|
||||
reviewPriority: undefined, reviewOwner: undefined, reviewDueDate: undefined, score: 96
|
||||
});
|
||||
const items = [reviewed, unreviewed];
|
||||
expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields);
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets')
|
||||
.mockImplementation(async query => deriveUnifiedResultFacets(items, query));
|
||||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults')
|
||||
.mockImplementation(async query => queryUnifiedResultItems(items, query));
|
||||
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<ResultRow item={reviewed} expanded={true} onToggle={() => undefined}
|
||||
workRecordIds={[]} onOpenInWork={() => undefined}/>));
|
||||
expect(container.textContent).not.toMatch(/review|Synthetic Owner/i);
|
||||
|
||||
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||||
const exportButton = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Export JSONL')!;
|
||||
await act(async () => exportButton.click());
|
||||
const text = download.mock.calls[0][0].text;
|
||||
const results = text.trimEnd().split('\n').slice(1).map(line => JSON.parse(line).result);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every(result => result.disclosureTrace)).toBe(true);
|
||||
expect(text).not.toMatch(/review|Synthetic Owner/i);
|
||||
expect(results.some(result => 'score' in result)).toBe(false);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it('shows the stored trace of a row and opens its original in Work without showing it in the Result DB', async () => {
|
||||
const stored = discloseResultDbProjectionWithTrace({
|
||||
...reviewedFinding('traced'), affectedAsset: 'SYNTHETIC-HOST-9', description: 'Host SRVDC01 answers for 3 hosts.'
|
||||
}, new ResultDbIdentityGuard(['SRVDC01']));
|
||||
const opened = vi.fn();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<ResultRow item={stored} expanded={true} onToggle={() => undefined}
|
||||
workRecordIds={['work-record-1']} onOpenInWork={opened}/>));
|
||||
const detail = container.querySelector('.results-disclosure-trace')?.textContent ?? '';
|
||||
expect(detail).toContain('Checked content');
|
||||
expect(detail).toContain('Redacted in Description: count × 1, identity-guard × 1');
|
||||
expect(detail).toContain('Withheld fields:');
|
||||
expect(container.textContent).not.toContain('SRVDC01');
|
||||
const button = [...container.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent === 'Open original in Work')!;
|
||||
await act(async () => button.click());
|
||||
expect(opened).toHaveBeenCalledWith(['work-record-1']);
|
||||
|
||||
await act(async () => root.render(<ResultRow item={stored} expanded={true} onToggle={() => undefined} workRecordIds={[]} onOpenInWork={opened}/>));
|
||||
expect(container.textContent).toContain('The original is not part of the loaded Work scope.');
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ function result(id: string, classification: NonNullable<UnifiedResultProjection[
|
|||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
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 = {
|
||||
...result('gpo-readable', 'misconfigured'),
|
||||
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)
|
||||
.toBe('registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators');
|
||||
const detail = container.querySelector('.results-browser-row-detail')?.textContent ?? '';
|
||||
expect(detail).toContain('Configured customer value');
|
||||
expect(detail).toContain('Data: 0');
|
||||
// 2026-09-28: the Result DB shows the Soll value and relation only; the customer value stays in Work.
|
||||
expect(detail).not.toContain('Configured customer value');
|
||||
expect(detail).not.toContain('Data: 0');
|
||||
expect(detail).toContain('Expected baseline value');
|
||||
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('Microsoft Security Baseline Windows Server 2022 v1');
|
||||
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('Baseline: Microsoft Security Baseline - Windows Server');
|
||||
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.toContain('Privacy-safe Group Policy comparison');
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
||||
|
||||
function dataset(records: Array<{ id: string; findingOccurrenceId?: string }>): ImportedDataset {
|
||||
return { sheets: [{ analysis: { records } }] } as unknown as ImportedDataset;
|
||||
}
|
||||
|
||||
describe('Result DB original lookup in Work', () => {
|
||||
it('finds the Work record of a scanner row by its finding occurrence', () => {
|
||||
const row = { sourceKind: 'scanner-finding', sourceResultId: 'occurrence-7' } as UnifiedResultProjection;
|
||||
expect(workRecordIdsForResult(dataset([{ id: 'record-a', findingOccurrenceId: 'occurrence-7' },
|
||||
{ id: 'record-b', findingOccurrenceId: 'occurrence-8' }]), row)).toEqual(['record-a']);
|
||||
});
|
||||
|
||||
it('finds the Work setting of a GPO comparison by scope and technical setting identity', () => {
|
||||
const key = (scope: string, identity: string) =>
|
||||
`gpo-work-setting:${encodeURIComponent(JSON.stringify(['project', 'aggregation', scope, identity]))}`;
|
||||
const row = { sourceKind: 'policy-evaluation', policyScope: 'machine', sourceRuleId: 'registry:machine:software\\x:y' } as unknown as UnifiedResultProjection;
|
||||
expect(workRecordIdsForResult(dataset([{ id: key('machine', 'registry:machine:software\\x:y') },
|
||||
{ id: key('user', 'registry:machine:software\\x:y') }, { id: 'gpo-work-setting:%7Bbroken' }]), row))
|
||||
.toEqual([key('machine', 'registry:machine:software\\x:y')]);
|
||||
});
|
||||
|
||||
it('returns nothing without a loaded Work dataset or for environment rows', () => {
|
||||
expect(workRecordIdsForResult(null, { sourceKind: 'scanner-finding', sourceResultId: 'x' } as UnifiedResultProjection)).toEqual([]);
|
||||
expect(workRecordIdsForResult(dataset([{ id: 'r', findingOccurrenceId: 'x' }]),
|
||||
{ sourceKind: 'environment-observation', sourceResultId: 'x' } as UnifiedResultProjection)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -119,18 +119,54 @@ describe('scanner first read', () => {
|
|||
expect(headings(container)).toEqual(['Description', 'Rationale']);
|
||||
});
|
||||
|
||||
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'],
|
||||
['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 { container, values } = render(item, 'maester', [results]);
|
||||
expect(values('.finding-first-read')).toEqual({ Results: '1 of 3 rows marked failed · 1 marked passed' });
|
||||
expect(container.querySelector('.reader-summary')?.textContent).not.toContain('Synthetic native test result');
|
||||
expect(values('.finding-first-read')).toEqual({ Result: 'Synthetic native test result', Results: '1 of 3 rows marked failed · 1 marked passed' });
|
||||
const link = container.querySelector<HTMLAnchorElement>('.policy-next-step a')!;
|
||||
expect(link.getAttribute('href')).toBe('https://maester.dev/docs/tests/MT.9001');
|
||||
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
|
||||
// `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.
|
||||
|
|
|
|||
|
|
@ -128,6 +128,42 @@ async function commit(
|
|||
}
|
||||
|
||||
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 () => {
|
||||
const repository = new LocalAssessorRepository();
|
||||
await repository.saveProject(project);
|
||||
|
|
@ -225,11 +261,16 @@ describe('Unified result read model', () => {
|
|||
isIoe: true,
|
||||
exposureState: 'detected',
|
||||
lifecycleState: 'unknown',
|
||||
reviewStatus: 'resolved',
|
||||
reviewDisposition: 'accepted',
|
||||
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({
|
||||
outcome: ['execution_error'],
|
||||
|
|
@ -333,6 +374,13 @@ describe('Unified result read model', () => {
|
|||
normalizedValue: 'private.synthetic.example', valueType: 'string', state: 'observed',
|
||||
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/1' },
|
||||
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();
|
||||
|
|
@ -340,34 +388,36 @@ describe('Unified result read model', () => {
|
|||
const results = await repository.queryUnifiedResults({
|
||||
projectId: project.id,
|
||||
sourceKind: ['environment-observation'],
|
||||
environmentProperty: ['domain.functional_level'],
|
||||
environmentProperty: ['domain.recycle_bin_enabled'],
|
||||
environmentSubjectType: ['active-directory-environment'],
|
||||
environmentState: ['observed'],
|
||||
search: '2016'
|
||||
environmentState: ['observed']
|
||||
});
|
||||
expect(results).toMatchObject({
|
||||
total: 1,
|
||||
items: [expect.objectContaining({
|
||||
resultProjectionId: 'environment-observation:environment-dfl',
|
||||
resultProjectionId: 'environment-observation:environment-recycle-bin',
|
||||
sourceKind: 'environment-observation',
|
||||
sourceTool: 'pingcastle',
|
||||
sourceProfileVersion: '1.5.0',
|
||||
mappingRuleVersion: '1.2.0+context-1.0.0',
|
||||
processingStatus: 'current',
|
||||
environmentProperty: 'domain.functional_level',
|
||||
environmentProperty: 'domain.recycle_bin_enabled',
|
||||
environmentSubjectType: 'active-directory-environment',
|
||||
environmentNormalizedValue: 2016,
|
||||
environmentValueType: 'number',
|
||||
environmentNormalizedValue: true,
|
||||
environmentValueType: 'boolean',
|
||||
environmentState: 'observed',
|
||||
supplementalDisclosurePolicyVersion: '1.0.0',
|
||||
supplementalDisclosureClass: 'technical-metadata',
|
||||
supplementalDisclosurePolicyVersion: '2.0.0',
|
||||
supplementalDisclosureClass: 'configuration-state',
|
||||
resultType: 'informational',
|
||||
isIoe: 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'))
|
||||
.resolves.toMatchObject({ sourceResultId: 'environment-dfl', environmentNormalizedValue: 2016 });
|
||||
.resolves.toBeNull();
|
||||
await expect(repository.getUnifiedResultById('environment-observation:environment-domain-name'))
|
||||
.resolves.toBeNull();
|
||||
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)
|
||||
.toContainEqual(expect.objectContaining({ value: 'environment-observation', count: 1 }));
|
||||
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)
|
||||
.toContainEqual(expect.objectContaining({ value: 'observed', count: 1 }));
|
||||
expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values)
|
||||
|
|
|
|||
|
|
@ -123,7 +123,12 @@ describePostgres('PostgresAssessorRepository integration', () => {
|
|||
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',
|
||||
reviewPriority: 'high'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { databaseMigrations } from '../../server/postgres/migration-catalog';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..', '..', 'server', 'postgres');
|
||||
|
||||
describe('PostgreSQL materialized Result DB migration', () => {
|
||||
it('is registered as an additive migration after reference recognition', () => {
|
||||
expect(databaseMigrations.find(migration => migration.version === '024')).toMatchObject({
|
||||
version: '024', name: 'result-db-materialization'
|
||||
});
|
||||
});
|
||||
|
||||
it('stores derived rows per client with cascade deletion and one state record', async () => {
|
||||
const sql = await readFile(resolve(root, 'migrations', '024_result_db_materialization.sql'), 'utf8');
|
||||
expect(sql).toContain('CREATE TABLE assessor_result_db_rows');
|
||||
expect(sql).toContain('REFERENCES assessor_projects(id) ON DELETE CASCADE');
|
||||
expect(sql).toContain('CREATE TABLE assessor_result_db_state');
|
||||
expect(sql).not.toMatch(/\bDROP\b|\bALTER TABLE assessor_(?!result_db)/u);
|
||||
});
|
||||
|
||||
it('serves every Result DB read from the stored rows and rebuilds after committed changes', async () => {
|
||||
const repository = await readFile(resolve(root, 'postgres-assessor-repository.ts'), 'utf8');
|
||||
expect(repository).toContain("if (query.audience !== 'internal-analysis') return this.storedResultDbRows(query.projectId);");
|
||||
expect(repository).toContain("'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1'");
|
||||
expect(repository).toContain('if (changed(result)) this.scheduleResultDbRebuild();');
|
||||
expect(repository).toContain('pg_advisory_lock($1)');
|
||||
expect(repository).not.toContain('discloseResultDbProjection(');
|
||||
});
|
||||
});
|
||||
|
|
@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
|
|||
|
||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -461,7 +461,9 @@ describe('baseline-driven policy evaluation', () => {
|
|||
policyBaselineVariantAssessment: saved.results[0].baselineVariantAssessment
|
||||
});
|
||||
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('Configured Group Policy value compared with the retained baseline recommendation.');
|
||||
// The saved per-variant values and verdicts remain available consistently in Work guidance
|
||||
|
|
|
|||
Loading…
Reference in New Issue