205 lines
12 KiB
TypeScript
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
|
|
};
|
|
}
|