Assessor_2.0/src/features/project-import/load-canonical-project-work...

205 lines
12 KiB
TypeScript

import { showActivityFrame } from '../../platform/activity-store';
import type { Assessment, CanonicalAssessmentModel } from '../../domain/canonical-model';
import type { ImportedDataset } from '../../domain/dataset';
import type { StoredPolicyEvaluation } from '../../domain/policy-evaluation';
import { selectCurrentPolicyEvaluations, type UnifiedResultQuery, type UnifiedResultQueryResult } from '../../domain/unified-result';
import type { PolicyAnalysisOverview } from '../../domain/policy-analysis-overview';
import { queryAllUnifiedResults } from '../../exports/unified-result-jsonl';
import { canonicalModelsToDataset } from './canonical-model-to-dataset';
import { appendPolicyObservationsToDataset } from './policy-observations-to-dataset';
import type { StoredMicrosoftSecurityBaselineAnalysis } from '../../domain/microsoft-security-baseline-analysis';
import { buildPolicyGuidance } from '../../profiles/group-policy/guidance';
import { canonicalSecurityScores } from '../../scoring/canonical-security-score';
import { defaultSecurityScoringPolicy, type SecurityScanScore } from '../../domain/security-scoring';
export interface CanonicalProjectReader {
listProjectAssessments(projectId: string): Promise<Assessment[]>;
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel>;
queryUnifiedResults?(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
listPolicyEvaluations?(projectId: string): Promise<{ records: StoredPolicyEvaluation[] }>;
listCurrentPolicyEvaluationsForMetrics?(projectId: string): Promise<{ records: StoredPolicyEvaluation[] }>;
getPolicyAnalysisOverview?(projectId: string): Promise<Pick<PolicyAnalysisOverview, 'current'>>;
listMicrosoftSecurityBaselineAnalyses?(baselineId: string): Promise<{ records: StoredMicrosoftSecurityBaselineAnalysis[] }>;
}
export interface CanonicalProjectWorkspace {
models: CanonicalAssessmentModel[];
dataset: ImportedDataset | null;
assessmentIds: string[];
}
export interface CanonicalWorkspaceLoadOptions {
onProgress?(label: string): void;
includeHistoricalScores?: boolean;
includeBaselineGuidance?: boolean;
summaryOnly?: boolean;
}
export function canonicalModelForScanRuns(
model: CanonicalAssessmentModel,
scanRunIds: string[]
): CanonicalAssessmentModel | null {
const selectedIds = new Set(scanRunIds);
const scanRuns = model.scanRuns.filter(run => selectedIds.has(run.id));
if (!scanRuns.length) return null;
const importFiles = model.importFiles.filter(file => selectedIds.has(file.scanRunId));
const fileIds = new Set(importFiles.map(file => file.id));
const sourceTables = model.sourceTables.filter(table => fileIds.has(table.importFileId));
const tableIds = new Set(sourceTables.map(table => table.id));
const findingOccurrences = model.findingOccurrences.filter(occurrence => selectedIds.has(occurrence.scanRunId));
const occurrenceIds = new Set(findingOccurrences.map(occurrence => occurrence.id));
const definitionIds = new Set(findingOccurrences.map(occurrence => occurrence.definitionId));
return {
...model,
referenceRuns: model.referenceRuns?.filter(run => selectedIds.has(run.scanRunId)),
scanRuns,
importFiles,
sourceTables,
findingDefinitions: model.findingDefinitions.filter(definition => definitionIds.has(definition.id)),
findingOccurrences,
evidence: model.evidence.filter(record => occurrenceIds.has(record.occurrenceId)),
reviews: model.reviews.filter(review => review.target.kind === 'definition'
? definitionIds.has(review.target.definitionId)
: occurrenceIds.has(review.target.occurrenceId)),
diagnostics: model.diagnostics.filter(diagnostic =>
fileIds.has(diagnostic.importFileId)
&& (diagnostic.sourceTableId === undefined || tableIds.has(diagnostic.sourceTableId))),
environmentObservations: model.environmentObservations?.filter(item => selectedIds.has(item.scanRunId) && fileIds.has(item.importFileId)),
reportSections: model.reportSections?.filter(item => selectedIds.has(item.scanRunId) && fileIds.has(item.importFileId))
};
}
export function canonicalModelForScanRun(model: CanonicalAssessmentModel, scanRunId: string): CanonicalAssessmentModel | null {
return canonicalModelForScanRuns(model, [scanRunId]);
}
export function currentPolicyEvaluationsForWork(
records: StoredPolicyEvaluation[],
observationIds: Set<string>,
currentEvaluationIds?: ReadonlySet<string>
): StoredPolicyEvaluation[] {
// The backend owns profile/identity/snapshot supersession. Result coverage
// (in particular the referenced backup subset) is not a source identity.
const allowedAggregations = currentEvaluationIds === undefined ? undefined : new Set(records
.filter(record => currentEvaluationIds.has(record.run.id)).map(record => record.run.aggregationRunId));
return selectCurrentPolicyEvaluations(records.filter(record =>
(allowedAggregations === undefined || allowedAggregations.has(record.run.aggregationRunId))
&& record.results.some(result => result.observationIds.some(id => observationIds.has(id)))
&& record.results.every(result => result.observationIds.every(id => observationIds.has(id)))
), { allowPreviousVersionForDisplay: true });
}
export async function loadCanonicalProjectWorkspace(
reader: CanonicalProjectReader,
projectId: string,
scanRunIds?: string[],
selectedAssessmentIds?: string[],
options: CanonicalWorkspaceLoadOptions = {}
): Promise<CanonicalProjectWorkspace> {
options.onProgress?.('Loading assessments and scan data');
const assessments = await reader.listProjectAssessments(projectId);
const selectedAssessments = selectedAssessmentIds
? assessments.filter(assessment => selectedAssessmentIds.includes(assessment.id))
: assessments;
// In a selected timeslot, policy observation lookup needs only the assessment
// IDs. Start it alongside model loading; both can be expensive for large scans.
const selectedPolicyResults = selectedAssessmentIds && reader.queryUnifiedResults && selectedAssessments.length
? queryAllUnifiedResults(
query => reader.queryUnifiedResults!(query),
{ audience: 'internal-analysis', projectId, assessmentIds: selectedAssessments.map(item => item.id), sourceKind: ['policy-observation'] }
)
: undefined;
const loadedModels = await Promise.all(selectedAssessments.map(assessment => reader.loadAssessmentModel(assessment.id)));
const models = scanRunIds?.length
? loadedModels.map(model => canonicalModelForScanRuns(model, scanRunIds))
.filter((model): model is CanonicalAssessmentModel => model !== null)
: loadedModels;
// A dated timeslot can contain scanner runs and GPO results stored in separate
// assessments. Scanner scoping deliberately removes models without one of the
// selected runs, but it must not also erase the explicit assessment scope used
// by policy observations. Otherwise the same GPO rows disappear from Work and
// Client Info as soon as that mixed timeslot is opened.
const assessmentIds = [...new Set((selectedAssessmentIds
? selectedAssessments
: models).map(item => 'assessment' in item ? item.assessment.id : item.id))];
const policyObservations = selectedPolicyResults ? await selectedPolicyResults
: reader.queryUnifiedResults && assessmentIds.length
? await queryAllUnifiedResults(
query => reader.queryUnifiedResults!(query),
{ audience: 'internal-analysis', projectId, assessmentIds, sourceKind: ['policy-observation'] }
)
: [];
options.onProgress?.('Preparing scanner findings and scores');
if (options.onProgress) await showActivityFrame();
const scannerDataset = canonicalModelsToDataset(models, loadedModels, { summaryOnly: options.summaryOnly });
if (scannerDataset?.securityScoring && options.includeHistoricalScores !== false) {
const currentIds = new Set(assessmentIds);
const currentDates = models.map(model => Date.parse(model.assessment.effectiveAt ?? model.assessment.createdAt)).filter(Number.isFinite);
const cutoff = currentDates.length ? Math.min(...currentDates) : undefined;
const historical = cutoff === undefined ? [] : assessments.filter(item => !currentIds.has(item.id)
&& Date.parse(item.effectiveAt ?? item.createdAt) < cutoff);
const loadedById = new Map(loadedModels.map(model => [model.assessment.id, model]));
const lastKnown = new Map<string, SecurityScanScore>();
// Workspace-owned, read-only projection. Missing later scans retain their last
// known open obligations; historical scores are never relabelled as current.
for (const assessment of historical.sort((a, b) => Date.parse(a.effectiveAt ?? a.createdAt) - Date.parse(b.effectiveAt ?? b.createdAt))) {
try {
const historicModel = loadedById.get(assessment.id) ?? await reader.loadAssessmentModel(assessment.id);
for (const scan of canonicalSecurityScores([historicModel])) {
lastKnown.set(scan.comparisonKey ?? `unverified:${scan.toolId}:${scan.contentSha256 ?? scan.id}`, scan);
}
} catch { scannerDataset.securityScoring.historyUnavailable = true; }
}
if (historical.length) scannerDataset.securityScoring.baseline = [...lastKnown.values()];
}
const currentMetricEvaluations = options.summaryOnly && reader.listCurrentPolicyEvaluationsForMetrics;
const [evaluationList, policyOverview] = policyObservations.length && reader.listPolicyEvaluations
? await Promise.all([
(currentMetricEvaluations
? currentMetricEvaluations(projectId)
: reader.listPolicyEvaluations(projectId)),
currentMetricEvaluations ? undefined : reader.getPolicyAnalysisOverview?.(projectId)
])
: [];
const evaluations = currentPolicyEvaluationsForWork(
evaluationList?.records ?? [],
new Set(policyObservations.map(result => result.sourceResultId)),
policyOverview ? new Set(policyOverview.current.evaluationRunIds) : undefined
);
const projectName = models[0]?.project.name ?? assessments.find(item => assessmentIds.includes(item.id))?.name ?? projectId;
const guidance = buildPolicyGuidance(evaluations);
// One request per referenced baseline, sequential to bound catalog memory. Optional
// documentation must not prevent opening otherwise usable assessment results.
if (options.includeBaselineGuidance !== false && reader.listMicrosoftSecurityBaselineAnalyses) {
const byBaseline = new Map<string, StoredPolicyEvaluation[]>();
for (const evaluation of evaluations) for (const baselineId of new Set(evaluation.results.map(result => result.baselineId))) {
byBaseline.set(baselineId, [...(byBaseline.get(baselineId) ?? []), {
...evaluation, results: evaluation.results.filter(result => result.baselineId === baselineId)
}]);
}
for (const [baselineId, records] of byBaseline) {
try {
const response = await reader.listMicrosoftSecurityBaselineAnalyses(baselineId);
for (const [id, value] of buildPolicyGuidance(records, response.records)) guidance.set(id, value);
} catch {
// A failed documentation request is distinct from absent source metadata.
for (const record of records) for (const result of record.results) {
const retained = guidance.get(result.id);
if (retained) guidance.set(result.id, { ...retained, descriptionLoadFailed: true });
}
}
}
}
options.onProgress?.('Combining scanner findings and GPO results');
if (options.onProgress) await showActivityFrame();
const dataset = appendPolicyObservationsToDataset(scannerDataset, projectName, policyObservations, evaluations, guidance);
if (dataset && !dataset.securityScoring) dataset.securityScoring = {
policy: models[0]?.assessment.scoringPolicy ?? defaultSecurityScoringPolicy(), scans: []
};
return {
models,
assessmentIds,
dataset
};
}