Assessor_2.0/src/compliance/privacy-safe-export.ts

167 lines
7.5 KiB
TypeScript

import type { UnifiedResultProjection } from '../domain/unified-result';
import { POLICY_CLASSIFICATION_LABELS } from '../domain/policy-status-labels';
import { discloseResultDbProjection } from '../domain/result-db-disclosure';
import { privacySafeFinding, privacySafeText } from '../domain/privacy-safe-finding';
import { canonicalJson } from '../shared/canonical-json';
import type { RegulatoryFindingQuery } from './regulatory-rag';
import { buildRegulatoryFindingQuery } from './regulatory-query';
const SAFE_ENVIRONMENT_PROPERTIES = new Set([
'domain.controller_count',
'domain.functional_level',
'domain.recycle_bin_enabled',
'forest.functional_level',
'logging.wef.configuration_count',
'operating_system.count',
'operating_system.name'
]);
export function privacySafeResultJsonFileName(generatedAt: string): string {
const timestamp = generatedAt.replace(/\.\d{3}Z$/, 'Z').replaceAll(':', '-');
return `assessor-result-db-privacy-safe-${timestamp}.json`;
}
function stableResultKey(item: UnifiedResultProjection): string {
return [item.sourceKind, item.sourceTool ?? '', item.sourceRuleId ?? '', item.resultProjectionId].join('\u0000');
}
/** The exact JSON the AI overview sends to the model, in its deterministic order. */
export function aiOverviewInputJson(items: UnifiedResultProjection[]): string {
return resultsToPrivacySafeExportJson(items.slice().sort((left, right) => stableResultKey(left).localeCompare(stableResultKey(right))));
}
export interface ComplianceAiExport {
schemaVersion: 2;
/** Exactly what the AI overview receives for these rows (it splits the same object into chunks when it is large). */
aiOverviewInput: unknown;
/** Each distinct finding query the ISO mapping model receives, with the Result DB rows that produce it. */
isoMappingQueries: Array<{ query: RegulatoryFindingQuery; resultProjectionIds: string[] }>;
}
/**
* The Result DB `Export JSON` of the Compliance Engine: a 1:1 copy of what its AI features receive,
* built by the same functions they use. The ISO model additionally receives the retrieved candidate
* controls, which depend on the index and are not part of this offline export.
*/
export function complianceAiExportJson(items: UnifiedResultProjection[]): string {
const queries = new Map<string, ComplianceAiExport['isoMappingQueries'][number]>();
for (const item of items.slice().sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId))) {
const query = buildRegulatoryFindingQuery(item);
if (!query) continue;
const key = canonicalJson(query);
const entry = queries.get(key) ?? { query, resultProjectionIds: [] };
entry.resultProjectionIds.push(item.resultProjectionId);
queries.set(key, entry);
}
const value: ComplianceAiExport = {
schemaVersion: 2,
aiOverviewInput: JSON.parse(aiOverviewInputJson(items)) as unknown,
isoMappingQueries: [...queries.values()]
};
return JSON.stringify(value, null, 2);
}
export function resultsToPrivacySafeExportJson(items: UnifiedResultProjection[]): string {
// A raw GPO observation is context for its rolled-up comparison, not a finding of its own.
const findings = items.filter(item => item.sourceKind !== 'environment-observation' && item.sourceKind !== 'policy-observation');
const policyObservations = items.filter(item => item.sourceKind === 'policy-observation');
const policyFindings = findings.filter(item => item.sourceKind === 'policy-evaluation');
const environment = items.filter(item => item.sourceKind === 'environment-observation');
const actionable = findings.filter(item =>
item.isDeviation || item.isIoe || ['fail', 'warning', 'execution_error'].includes(item.outcomeNormalized));
const notRun = findings.filter(item => item.outcomeNormalized === 'not_run');
return JSON.stringify({
schemaVersion: 1,
summary: {
totalResults: items.length,
findingResults: findings.length,
environmentResults: environment.length,
policyObservationResults: policyObservations.length,
actionableFindings: actionable.length,
notRunChecks: notRun.length,
findingOutcomes: countBy(findings, item => item.outcomeNormalized),
findingSeverities: countBy(findings, item => item.severityNormalized),
findingTools: countBy(findings, item => item.sourceTool ?? 'unknown'),
policyStatuses: countBy(policyFindings, policyStatus),
environmentStates: countBy(environment, item => item.environmentState ?? 'unknown')
},
findings: actionable.map(item => ({ ...privacySafeFinding(item), ...privacySafePolicy(item) })),
notRunChecks: notRun.map(item => ({
id: item.sourceRuleId ?? null,
title: privacySafeText(item.title),
severity: item.severityNormalized,
resultType: item.resultType,
tool: item.sourceTool ?? 'unknown'
})),
environmentFacts: groupedEnvironmentFacts(environment)
}, null, 2);
}
function policyStatus(item: UnifiedResultProjection): string {
return item.policyClassification ? POLICY_CLASSIFICATION_LABELS[item.policyClassification] : 'unknown';
}
/** GPO comparison facts under the Result DB disclosure policy: the customer value stays withheld. */
function privacySafePolicy(item: UnifiedResultProjection): { policy?: Record<string, unknown> } {
if (item.sourceKind !== 'policy-evaluation') return {};
const disclosed = discloseResultDbProjection(item);
const policy = {
status: policyStatus(disclosed),
baseline: disclosed.policyBaselineLabel,
expectedValue: disclosed.policyBaselineValue,
comparison: disclosed.policyComparisonRelation,
customerState: disclosed.policyCustomerState,
scope: disclosed.policyScope,
delivery: disclosed.policyDeliveryMechanism,
diagnostics: disclosed.policyDiagnosticCodes?.length ? disclosed.policyDiagnosticCodes : undefined
};
return { policy: Object.fromEntries(Object.entries(policy).filter(([, value]) => value !== undefined)) };
}
function groupedEnvironmentFacts(items: UnifiedResultProjection[]) {
const grouped = new Map<string, {
title: string;
property: string;
value: unknown;
state: string;
tool: string;
count: number;
}>();
items.forEach(item => {
const property = copiedEnvironmentProperty(item);
if (!property) return;
const fact = {
title: privacySafeText(item.title),
property,
value: item.environmentNormalizedValue ?? null,
state: item.environmentState ?? 'unknown',
tool: item.sourceTool ?? 'unknown'
};
const key = JSON.stringify(fact);
const existing = grouped.get(key);
if (existing) existing.count += 1;
else grouped.set(key, { ...fact, count: 1 });
});
return [...grouped.values()];
}
function countBy(items: UnifiedResultProjection[], valueOf: (item: UnifiedResultProjection) => string): Record<string, number> {
const counts: Record<string, number> = {};
items.forEach(item => {
const value = valueOf(item);
counts[value] = (counts[value] ?? 0) + 1;
});
return counts;
}
function copiedEnvironmentProperty(item: UnifiedResultProjection): string | undefined {
if (item.sourceKind !== 'environment-observation' || !item.environmentProperty) return undefined;
if (SAFE_ENVIRONMENT_PROPERTIES.has(item.environmentProperty) || item.environmentProperty.endsWith('.row_count')) {
return item.environmentProperty;
}
const aggregateAccountValue = item.environmentValueType === 'number'
&& ['user-information.account-analysis', 'computer-information.account-analysis'].includes(item.environmentSubjectType ?? '');
return aggregateAccountValue ? item.environmentProperty : undefined;
}