feat(regulatory): map GPO baseline comparisons to ISO controls

policy-evaluation rows map like scanner findings in the Finding Reader and the filtered Result DB batch. The query is built after the Result DB disclosure projection, so the customer value never reaches retrieval or the mapper. The global rule key is the technical setting ID; Work GPO rows carry their current comparison ID. Raw GPO and environment observations stay excluded. Retrieval version unchanged.
This commit is contained in:
Binh 2026-10-02 11:20:19 +02:00
parent fb6712d3db
commit 841b5f9d02
12 changed files with 106 additions and 28 deletions

View File

@ -4223,6 +4223,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
| 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:<technische Setting-ID>`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. |
| 2026-10-02 | Phase 7: Privacy-Safe-`Export JSON` um GPO-Vergleiche ergaenzt. Handlungsrelevante `policy-evaluation`-Findings tragen einen `policy`-Block (Status, Baseline-Familie, Sollwert, Vergleich, Kundenzustand, Scope, Delivery, Diagnosecodes) ausschliesslich aus der bestehenden Result-DB-Disclosure-Projektion; der Kundenwert bleibt zurueckgehalten. Rohe `policy-observation`-Zeilen zaehlen nicht mehr als Findings, sondern separat als `policyObservationResults`; `policyStatuses` zaehlt die GPO-Urteile. Synthetischer Test in `tests/exports/result-db-copy.test.ts`. |
| 2026-10-02 | Phase 7: AI-Integration auf den aktuellen Michael-Stand uebernommen (Branch `merge/michael-binh`). Der Privacy-Safe-`Export JSON` aus `result-db-copy` ergaenzt die Result DB als vierte Exportaktion; `Export JSONL`, `Export Excel` und `Export CSV` bleiben unveraendert. Die Einzelaktions-Festlegung vom 2026-09-14 gilt auf diesem Stand deshalb nicht als Ersatz des JSONL-Exports. Die Beispieldateien `test.json`, `test-output.json` und `artifacts/test-output-*` wurden nicht uebernommen. |
| 2026-09-26 | Phase 7: MITRE-Onlineaktualisierung aus offizieller Enterprise-STIX-Quelle, separater Download der originalen MITRE-Datei und weiterhin manueller JSON-Import. Gemeinsam gespeicherter privater Katalog, sofortige Graphaktualisierung, Same-Origin-API; keine Assessment-/DB-Migration. Minorversion 2.7.0; synthetische Service-/HTTP-/UI-Tests, Live-Update und Originaldownload, Design-/Local-only-Pruefung sowie beide Builds verifiziert. Sechs vorbestehende fachfremde Tests bleiben offen. |

View File

@ -114,6 +114,8 @@ Embedding is an external processing boundary. Regulatory text is sent during ing
The query never reads the **result table / occurrence data**: affected asset, source locator/path, per-object values, project/tenant identity, review owner, review text, original values, or provenance IDs. Because the query is built from the rule-level check text, **every scanner finding with a check title maps**; only non-scanner records and findings without a check title fail closed as `insufficient-public-context` and do not call the retriever. The local `sourceResultId` is returned only for in-process correlation and is not included in the embedding request.
GPO baseline comparisons (2026-10-02, explicit user request): `policy-evaluation` rows map the same way as scanner findings. The query uses the setting title, guidance text and recommendation after the Result DB disclosure projection, so the configured customer value, its principals and source counts never enter retrieval or the mapper prompt. The public tool label is `Microsoft Group Policy`; there is no rule catalog for GPO settings, so the specificity stays `topic`. The global rule key is `GPOs:<technical setting ID>`: every baseline package comparison of one setting, in every project, shares one mapping and its decisions. In Work the rolled-up setting names one of its current stored comparisons; a historical comparison offers no mapping until it is re-evaluated. Raw GPO observations and environment observations remain excluded context rows. `REGULATORY_RETRIEVAL_VERSION` is unchanged by explicit user decision, because scanner queries are identical and existing scanner mappings stay reusable.
This is a technical minimization boundary. Activation still depends on the approved embedding/LiteLLM destinations, catalog coverage, retention terms, and the precise finding classes allowed to cross that boundary. Residual note: `privacySafeFinding` redacts emails only; it relies on definitions being rule-generic, so a scanner that folds an identifier (host, IP, path) into a *definition* title/description would carry it into the query. Strengthening that redaction is a shared decision affecting both AI features (see implementation plan).
## One-finding mapping workflow
@ -136,7 +138,7 @@ On the consolidated migration line (2026-10-02), the single migration 026 `regul
`Map all ISO controls` sits beside Refresh and Export JSON in Result DB. Both actions derive their scope from the same normalized `UnifiedResultQuery`: the selected assessment and scan-run IDs are the outer timeslot boundary, and every active Result DB filter narrows that scope. This includes deviation, source, severity, outcome, result type, processing state, review state, categories, and supported text/date filters. The default `deviation: true` filter therefore maps deviations only until the user changes or resets it. Offset, limit, client project IDs, and client audience values are discarded. Display pagination, Load more, and the number of mounted rows never change batch membership.
The server enumerates the complete query through stable pagination and fails the batch if the filtered total changes during enumeration. It counts non-scanner results as excluded, scanner findings without usable public check context as unsupported, and deduplicates eligible occurrences by global `ruleKey`. One deterministic representative is selected per rule. Current successful or active mappings are reused; missing mappings and previously failed mappings are atomically claimed and queued. Single-finding and batch work share the same queue with at most two concurrent retrieval/model pipelines, so simultaneous requests cannot create duplicate model calls for a deterministic mapping identity. A completed batch may still report individual rule failures.
The server enumerates the complete query through stable pagination and fails the batch if the filtered total changes during enumeration. It counts raw GPO observations and environment observations as excluded context rows, scanner findings without usable public check context as unsupported, and deduplicates eligible occurrences by global `ruleKey`. One deterministic representative is selected per rule. Current successful or active mappings are reused; missing mappings and previously failed mappings are atomically claimed and queued. Single-finding and batch work share the same queue with at most two concurrent retrieval/model pipelines, so simultaneous requests cannot create duplicate model calls for a deterministic mapping identity. A completed batch may still report individual rule failures.
The batch table (part of migration 026 on the consolidated line, originally migration 027) holds only project-scoped batch orchestration records: the normalized timeslot/filter snapshot, canonical filter hash, idempotency key, state, aggregate counters, timestamps, and bounded failure details. Global individual mapping rows remain authoritative; there is no batch-item table. Batch records are exported with their project and removed with project deletion. Active mappings and batches are marked failed after an interrupted server process; a later batch reuses successful work and retries only missing or failed rules. The migration is additive and does not change scanner processing, so no processing-manifest bump or reimport is required. After 027 had been applied, a later ordering-only edit incorrectly changed that migration and the checksum guard stopped backend startup. The file has been restored to its applied SHA-256 state and locked by regression; no follow-up schema migration is required.

View File

@ -29,10 +29,11 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
import { buildRegulatoryFindingQuery } from '../../src/profiles/public-search/regulatory-query';
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/profiles/public-search/regulatory-query';
import { canonicalJson } from '../../src/shared/canonical-json';
const POLL_AFTER_MS = 1_500;
const UNSUPPORTED_KIND_MESSAGE = 'Only canonical scanner findings and GPO baseline comparisons can be mapped in this version.';
const MAXIMUM_PROMPT_CHARACTERS = 48_000;
const BATCH_PAGE_SIZE = 500;
const DEFAULT_MAXIMUM_CONCURRENT_RUNS = 2;
@ -99,7 +100,7 @@ export class RegulatoryMappingService {
async get(projectId: string, resultProjectionId: string): Promise<RegulatoryMappingResponse> {
const finding = await this.finding(projectId, resultProjectionId);
const supported = finding.sourceKind === 'scanner-finding' && Boolean(buildRegulatoryFindingQuery(finding));
const supported = Boolean(buildRegulatoryFindingQuery(finding));
const availability = supported ? await this.availability() : { enabled: false };
const enabled = availability.enabled && supported;
const ruleKey = supported ? this.ruleKey(finding) : undefined;
@ -111,8 +112,8 @@ export class RegulatoryMappingService {
resultProjectionId,
generationEnabled: enabled,
state: run?.status ?? (enabled ? 'empty' : 'unsupported'),
...(!supported ? { disabledReason: finding.sourceKind !== 'scanner-finding'
? 'Only canonical scanner findings can be mapped in this version.'
...(!supported ? { disabledReason: !isRegulatoryMappableResult(finding)
? UNSUPPORTED_KIND_MESSAGE
: 'This finding has insufficient public context for regulatory mapping.' }
: !availability.enabled ? { disabledReason: availability.reason ?? this.options.disabledReason ?? 'Regulatory mapping is not configured.' } : {}),
...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}),
@ -124,7 +125,7 @@ export class RegulatoryMappingService {
async start(projectId: string, request: RegulatoryMappingCreateRequest): Promise<RegulatoryMappingResponse> {
if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
const finding = await this.finding(projectId, request.resultProjectionId);
if (finding.sourceKind !== 'scanner-finding') throw new RegulatoryMappingServiceError('unsupported_finding_kind', 'Only canonical scanner findings can be mapped in this version.', 422);
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
const claimed = await this.claim(finding, true);
if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined));
@ -182,7 +183,7 @@ export class RegulatoryMappingService {
async decide(projectId: string, request: RegulatoryMappingDecisionCreateRequest): Promise<RegulatoryMappingResponse> {
if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
const finding = await this.finding(projectId, request.resultProjectionId);
if (finding.sourceKind !== 'scanner-finding') throw new RegulatoryMappingServiceError('unsupported_finding_kind', 'Only canonical scanner findings can be mapped in this version.', 422);
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
const ruleKey = this.ruleKey(finding);
const comment = request.comment?.trim();
const decision: RegulatoryMappingDecision = {
@ -327,7 +328,7 @@ export class RegulatoryMappingService {
const representatives = new Map<string, UnifiedResultProjection>();
const counts = { ...emptyRegulatoryMappingBatchCounts(), filteredResultCount: results.length };
for (const finding of results) {
if (finding.sourceKind !== 'scanner-finding') { counts.excludedNonScannerCount += 1; continue; }
if (!isRegulatoryMappableResult(finding)) { counts.excludedNonScannerCount += 1; continue; }
if (!buildRegulatoryFindingQuery(finding)) { counts.unsupportedFindingCount += 1; continue; }
counts.eligibleFindingCount += 1;
const ruleKey = this.ruleKey(finding);

View File

@ -76,6 +76,8 @@ export interface FindingRecord {
policyBaselineLabels?: string[];
/** Structured current variant comparisons for compact reading; absent for historical or incomplete comparisons. */
policyBaselineComparisons?: PolicyBaselineComparison[];
/** Stored current baseline comparison of this setting; names its Result DB row, never a customer value. */
policyComparisonId?: string;
/** Original row identities allow earlier baseline-specific notes to remain readable. */
policyReviewMembers?: Array<{ id: string; baseline: string }>;
index: number;

View File

@ -36,7 +36,7 @@ import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_D
import { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '../../profiles/maester/presentation';
import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingPanel } from './RegulatoryMappingPanel';
import { useRegulatoryMapping } from './use-regulatory-mapping';
import { regulatoryMappingResultId, useRegulatoryMapping } from './use-regulatory-mapping';
interface Props {
record: FindingRecord;
@ -428,7 +428,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|| resultReferences.some(reference => /source-table:\d+/i.test(reference));
const updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
const regulatoryMapping = useRegulatoryMapping(projectId, record.findingOccurrenceId, backendStatus);
const regulatoryMapping = useRegulatoryMapping(projectId, regulatoryMappingResultId(record), backendStatus);
return (
<article className="finding-reader" data-group-scroll="work-reader">

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { BackendStatus } from '../../app/app-state';
import type { FindingRecord } from '../../domain/finding';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type {
RegulatoryMappingCandidate,
@ -11,14 +12,14 @@ const assessorApi = new AssessorApiClient();
export function useRegulatoryMapping(
projectId: string,
occurrenceId: string | undefined,
/** Result DB row of the record: a scanner finding occurrence or a GPO baseline comparison. */
resultProjectionId: string,
backendStatus: BackendStatus
) {
const [open, setOpen] = useState(false);
const [response, setResponse] = useState<RegulatoryMappingResponse>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const resultProjectionId = occurrenceId ? `scanner-finding:${occurrenceId}` : '';
useEffect(() => {
setOpen(false);
@ -82,7 +83,7 @@ export function useRegulatoryMapping(
return [...latest.values()].filter(value => value === 'accepted').length;
}, [response]);
const localDisabledReason = !occurrenceId ? 'This record has no canonical finding occurrence.'
const localDisabledReason = !resultProjectionId ? 'This record has no stored finding or current GPO baseline comparison.'
: backendStatus !== 'connected' ? 'The backend is not connected.'
: response && !response.generationEnabled ? response.disabledReason ?? 'Regulatory mapping is unavailable.' : undefined;
@ -98,3 +99,9 @@ export function useRegulatoryMapping(
disabledReason: localDisabledReason
};
}
/** Names the stored Result DB row an ISO mapping is keyed from; empty when the record has none. */
export function regulatoryMappingResultId(record: Pick<FindingRecord, 'findingOccurrenceId' | 'policyComparisonId'>): string {
if (record.findingOccurrenceId) return `scanner-finding:${record.findingOccurrenceId}`;
return record.policyComparisonId ? `policy-evaluation-comparison:${record.policyComparisonId}` : '';
}

View File

@ -192,6 +192,7 @@ export function appendPolicyObservationsToDataset(
record.policySourceEvidence = policyEntries[index].sourceEvidence;
record.policyBaselineLabels = policyEntries[index].baselineLabels;
record.policyBaselineComparisons = policyEntries[index].baselineComparisons;
if (!policyEntries[index].historical) record.policyComparisonId = policyEntries[index].entry.comparison?.id;
if (policyEntries[index].reviewMembers) {
record.id = `gpo-work-setting:${encodeURIComponent(policyEntries[index].entry.settingKey!)}`;
record.policyReviewMembers = policyEntries[index].reviewMembers!.map(member => ({ baseline: member.baseline,
@ -258,7 +259,8 @@ export function appendPolicyObservationsToDataset(
policyFailingBackupSources: policyEntries[index - first.analysis.records.length]?.failingBackupSources,
policySourceEvidence: policyEntries[index - first.analysis.records.length]?.sourceEvidence,
policyBaselineLabels: policyEntries[index - first.analysis.records.length]?.baselineLabels,
policyBaselineComparisons: policyEntries[index - first.analysis.records.length]?.baselineComparisons };
policyBaselineComparisons: policyEntries[index - first.analysis.records.length]?.baselineComparisons,
policyComparisonId: policyRecord?.policyComparisonId };
});
return combined;
})() : first;

View File

@ -98,7 +98,7 @@ export function RegulatoryMappingBatchDialog({ controller, filteredResultCount,
<p className="regulatory-batch-state" role="status">Status: <strong>{run.status === 'completed' && run.counts.failedRuleCount ? 'completed with failures' : run.status}</strong></p>
<dl className="regulatory-batch-counts">
<BatchCount label="Filtered results" value={counts!.filteredResultCount}/>
<BatchCount label="Excluded non-scanner" value={counts!.excludedNonScannerCount}/>
<BatchCount label="Excluded context rows" value={counts!.excludedNonScannerCount}/>
<BatchCount label="Eligible findings" value={counts!.eligibleFindingCount}/>
<BatchCount label="Unsupported findings" value={counts!.unsupportedFindingCount}/>
<BatchCount label="Distinct rules" value={counts!.distinctRuleCount}/>

View File

@ -2,6 +2,7 @@ import type { RegulatoryFindingQuery } from '../../domain/regulatory-rag';
import { REGULATORY_FINDING_QUERY_SCHEMA_VERSION } from '../../domain/regulatory-rag';
import type { UnifiedResultProjection } from '../../domain/unified-result';
import { privacySafeFinding } from '../../domain/privacy-safe-finding';
import { discloseResultDbProjection } from '../../domain/result-db-disclosure';
import catalog from './catalog.json';
import { publicSecurityTopics } from './public-security-topics';
@ -11,6 +12,7 @@ const MAXIMUM_RECOMMENDATION_CHARACTERS = 600;
const publicTools: Record<string, string> = {
'cis-cat': 'CIS-CAT / CIS Benchmarks',
gpos: 'Microsoft Group Policy',
maester: 'Maester',
pingcastle: 'PingCastle',
'purple-knight': 'Semperis Purple Knight'
@ -26,12 +28,14 @@ const publicRuleIds: Record<string, readonly string[]> = {
* description and recommendation projected through the shared privacy boundary
* ({@link privacySafeFinding}). That text describes the check, not the tenant: the
* result-table / occurrence data (affectedAsset, per-object values) is never read,
* and email addresses are redacted. Every scanner finding with a check title maps;
* and email addresses are redacted. Every scanner finding or GPO baseline comparison
* with a check title maps; the configured GPO customer value is never part of that text;
* catalog rule IDs and fixed public topics only sharpen the query when present.
*/
export function buildRegulatoryFindingQuery(finding: UnifiedResultProjection): RegulatoryFindingQuery | undefined {
if (finding.sourceKind !== 'scanner-finding') return undefined;
const safe = privacySafeFinding(finding);
if (!isRegulatoryMappableResult(finding)) return undefined;
// A GPO comparison crosses only through the Result DB disclosure policy, which withholds the customer value.
const safe = privacySafeFinding(finding.sourceKind === 'policy-evaluation' ? discloseResultDbProjection(finding) : finding);
const title = safe.title.trim().slice(0, MAXIMUM_TITLE_CHARACTERS);
if (!title) return undefined;
const toolId = finding.sourceTool?.trim().toLowerCase() ?? '';
@ -65,3 +69,8 @@ function recognizedRuleId(toolId: string, value: string | undefined): string | u
if (!candidate) return undefined;
return publicRuleIds[toolId]?.find(id => id.toLowerCase() === candidate.toLowerCase());
}
/** Rule-level results only: raw GPO observations and environment facts describe context, not a check. */
export function isRegulatoryMappableResult(finding: UnifiedResultProjection): boolean {
return finding.sourceKind === 'scanner-finding' || finding.sourceKind === 'policy-evaluation';
}

View File

@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { regulatoryMappingResultId } from '../../src/features/finding-reader/use-regulatory-mapping';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
@ -200,11 +201,20 @@ describe('GPO Finding Reader baseline comparison', () => {
expect(record.fields.find(field => field.label === 'Remediation')!.value.split('Computer policy:').length - 1).toBe(4);
});
it('names a current stored comparison of the setting for ISO control mapping', () => {
const { observation, evaluation } = fixture([1, 1]);
const { record } = read(observation, [evaluation]);
expect(record.findingOccurrenceId).toBeUndefined();
expect(evaluation.results.map(result => `policy-evaluation-comparison:${result.id}`)).toContain(regulatoryMappingResultId(record));
});
it('keeps the complete stored text for a historical comparison', () => {
const { observation, evaluation } = fixture([1, 1]);
evaluation.run.evaluator = { ...POLICY_EVALUATION_ENGINE, version: '1.8.0' };
const { record, container } = read(observation, [evaluation]);
expect(record.policyBaselineComparisons).toBeUndefined();
// A historical comparison is not a current Result DB row, so ISO mapping has nothing to name.
expect(regulatoryMappingResultId(record)).toBe('');
expect(container.querySelector('.policy-baseline-comparison')).toBeNull();
});

View File

@ -71,7 +71,24 @@ describe('regulatory finding retrieval boundary', () => {
expect(generic?.publicRuleId).toBeUndefined();
});
it('fails closed only for non-scanner records or findings without a check title', () => {
it('maps a GPO baseline comparison from its disclosed setting text and never from the customer value', () => {
const query = buildRegulatoryFindingQuery(finding({
resultProjectionId: 'policy-evaluation-comparison:synthetic', sourceKind: 'policy-evaluation', sourceTool: 'GPOs',
sourceRuleId: 'registry:machine:software\policies\synthetic:mode', title: 'Synthetic policy mode',
description: 'Controls the synthetic SMB signing mode.', recommendation: 'Enable the synthetic policy mode.',
policyBaselineLabel: 'DUMMY Microsoft Security Baseline Windows Server 2022 v1',
policyBaselineValue: { operation: 'set-value', data: { kind: 'dword', value: 1 } },
policyActualValue: { operation: 'set-value', data: { kind: 'dword', value: 0 }, principals: ['DUMMY\named.user'] }
}));
expect(query).toMatchObject({ specificity: 'topic', publicTool: 'Microsoft Group Policy' });
expect(query?.query).toContain('Synthetic policy mode');
expect(query?.publicRuleId).toBeUndefined();
expect(query?.query).not.toMatch(/named.user|DUMMY|policyActualValue/);
});
it('fails closed only for context records or findings without a check title', () => {
expect(buildRegulatoryFindingQuery(finding({ sourceKind: 'policy-observation' }))).toBeUndefined();
expect(buildRegulatoryFindingQuery(finding({ sourceKind: 'environment-observation' }))).toBeUndefined();
expect(buildRegulatoryFindingQuery(finding({ title: ' ' }))).toBeUndefined();
});

View File

@ -9,6 +9,7 @@ import type { AssessorRepository } from '../../src/persistence/assessor-reposito
const stamp = '2026-09-15T10:00:00.000Z';
// Result-table / occurrence data that must never reach the model or the query.
const resultTableAsset = 'DC01.customer.local';
const customerPolicyValue = 'CUSTOMER\synthetic.account';
function finding(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResultProjection {
return {
@ -164,12 +165,16 @@ describe('regulatory mapping service', () => {
expect(completed.run?.errorDetail).not.toContain(resultTableAsset);
});
it('maps the complete filtered timeslot, excludes non-scanner results and deduplicates global rules', async () => {
it('maps the complete filtered timeslot including GPO comparisons, excludes context rows and deduplicates global rules', async () => {
const findings = [
finding(),
finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
finding({ resultProjectionId: 'scanner-finding:occurrence-3', sourceResultId: 'occurrence-3', sourceRuleId: 'A-Second', title: 'Second synthetic check' }),
finding({ resultProjectionId: 'policy-evaluation:one', sourceResultId: 'policy-one', sourceKind: 'policy-evaluation', sourceRuleId: undefined }),
finding({ resultProjectionId: 'policy-observation:one', sourceResultId: 'policy-one', sourceKind: 'policy-observation', sourceRuleId: undefined }),
// Two baseline packages of one GPO setting share one global rule.
...['one', 'two'].map(id => finding({ resultProjectionId: `policy-evaluation-comparison:${id}`, sourceResultId: `comparison-${id}`,
sourceKind: 'policy-evaluation', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\policies\synthetic:mode',
title: 'Synthetic policy mode', policyActualValue: { principals: [customerPolicyValue] } })),
finding({ resultProjectionId: 'scanner-finding:unsupported', sourceResultId: 'unsupported', sourceRuleId: 'A-Unsupported', title: '' }),
finding({ resultProjectionId: 'scanner-finding:not-deviation', sourceResultId: 'not-deviation', sourceRuleId: 'A-Ignored', isDeviation: false })
];
@ -190,17 +195,39 @@ describe('regulatory mapping service', () => {
assessmentIds: ['assessment-1'], scanRunIds: ['run-1'], filters: { deviation: true, affectedAsset: resultTableAsset }
});
expect(response.run).toMatchObject({ status: 'completed', counts: {
filteredResultCount: 5,
filteredResultCount: 7,
excludedNonScannerCount: 1,
eligibleFindingCount: 3,
eligibleFindingCount: 5,
unsupportedFindingCount: 1,
distinctRuleCount: 2,
scheduledRuleCount: 2,
succeededRuleCount: 2,
distinctRuleCount: 3,
scheduledRuleCount: 3,
succeededRuleCount: 3,
failedRuleCount: 0
} });
expect(complete).toHaveBeenCalledTimes(2);
expect(complete.mock.calls.every(call => !String(call[1]).includes(resultTableAsset))).toBe(true);
expect(complete).toHaveBeenCalledTimes(3);
expect(complete.mock.calls.every(call => !String(call[1]).includes(resultTableAsset) && !String(call[1]).includes('synthetic.account'))).toBe(true);
});
it('maps a single GPO baseline comparison under its technical setting and refuses a raw observation', async () => {
const gpo = finding({ resultProjectionId: 'policy-evaluation-comparison:one', sourceResultId: 'comparison-one',
sourceKind: 'policy-evaluation', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\policies\synthetic:mode',
title: 'Synthetic policy mode', policyActualValue: { principals: [customerPolicyValue] } });
const observation = finding({ resultProjectionId: 'policy-observation:one', sourceResultId: 'observation-one', sourceKind: 'policy-observation' });
const complete = vi.fn(async (_system: string, _input: string) => ({ value: {
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
} }));
const svc = service(mockRepository([gpo, observation]), complete);
await svc.start('project-1', { resultProjectionId: gpo.resultProjectionId });
await svc.close();
const mapped = await svc.get('project-1', gpo.resultProjectionId);
expect(mapped).toMatchObject({ generationEnabled: true, state: 'succeeded', run: { ruleKey: 'GPOs:registry:machine:software\policies\synthetic:mode' } });
expect(complete).toHaveBeenCalledTimes(1);
expect(String(complete.mock.calls[0][1])).not.toContain('synthetic.account');
expect(await svc.get('project-1', observation.resultProjectionId)).toMatchObject({ generationEnabled: false, state: 'unsupported' });
await expect(svc.start('project-1', { resultProjectionId: observation.resultProjectionId })).rejects.toThrow('GPO baseline comparisons');
});
it('bounds concurrent mapping completions across a batch', async () => {