Assessor_2.0/server/gpo/gpo-automatic-analysis-serv...

93 lines
5.3 KiB
TypeScript

import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError } from '../../src/persistence/repository-errors';
import { inferGpoEnvironmentContexts } from './gpo-environment-context';
import { GpoPolicySnapshotService } from './gpo-policy-snapshot-service';
import { GpoPolicyEvaluationService } from './gpo-policy-evaluation-service';
import { GpoPolicyFindingService } from './gpo-policy-finding-service';
import { buildGpoAnalysisSnapshots } from '../../src/gpo/snapshot-builder';
import { currentBaselineAnalyses } from '../../src/domain/policy-analysis-overview';
export type AutomaticGpoAnalysisResult = {
state: 'context-required' | 'baseline-required' | 'review' | 'evaluated';
evaluationRunId?: string;
diagnosticCode?: string;
};
/** One uploaded backup set, one timeslot. Never combines unrelated historical scans. */
export class GpoAutomaticAnalysisService {
private readonly active = new Map<string, Promise<AutomaticGpoAnalysisResult>>();
constructor(private readonly repository: AssessorRepository,
private readonly snapshots: GpoPolicySnapshotService,
private readonly evaluations: GpoPolicyEvaluationService,
private readonly findings: GpoPolicyFindingService) {}
run(projectId: string, processingRunId: string): Promise<AutomaticGpoAnalysisResult> {
const key = `${projectId}:${processingRunId}`;
const existing = this.active.get(key);
if (existing) return existing;
const operation = this.execute(projectId, processingRunId).finally(() => this.active.delete(key));
this.active.set(key, operation);
return operation;
}
async reprocessProject(projectId: string): Promise<{ evaluated: number; review: number; failed: number }> {
let evaluated = 0;
let review = 0;
let failed = 0;
const analyses = await this.repository.listGpoPolicyAnalyses(projectId);
const superseded = new Set(analyses.flatMap(item => item.run.supersedesAnalysisRunId
? [item.run.supersedesAnalysisRunId]
: []));
for (const analysis of analyses.filter(item => !superseded.has(item.run.id))) {
try {
const result = await this.run(projectId, analysis.run.sourceProcessingRunId);
if (result.state === 'evaluated') evaluated += 1;
else review += 1;
} catch {
failed += 1;
}
}
return { evaluated, review, failed };
}
private async execute(projectId: string, processingRunId: string): Promise<AutomaticGpoAnalysisResult> {
const analysis = (await this.repository.listGpoPolicyAnalyses(projectId))
.find(record => record.run.sourceProcessingRunId === processingRunId);
if (!analysis) throw new RepositoryNotFoundError('GPO analysis missing.');
const artifact = (await this.repository.listImportArtifacts(projectId)).find(record => record.id === analysis.run.artifactId);
if (!artifact?.assessmentId) return { state: 'context-required' };
const environment = await this.repository.getEnvironmentSnapshot({ projectId, assessmentId: artifact.assessmentId });
const observedContexts = inferGpoEnvironmentContexts(environment.observations);
const contexts = [...observedContexts, {
id: 'gpo-baseline-comparison', kind: 'operating-system' as const,
productFamily: 'Group Policy', productVersion: 'All imported baselines', role: 'Any', scope: 'mixed' as const
}].filter((context, index, all) => all.findIndex(candidate => candidate.id === context.id) === index);
const instances = (await this.repository.listGpoBackupInstances(projectId))
.filter(item => item.processingRunId === processingRunId);
const snapshotCandidates = buildGpoAnalysisSnapshots(instances.map(item => item.instance), { createdAt: analysis.run.completedAt });
if (snapshotCandidates.length !== 1) return { state: 'review', diagnosticCode: 'policy-aggregation-multiple-snapshots' };
const profile = await this.snapshots.createEnvironmentProfile(projectId, {
name: `Detected environment · ${artifact.assessmentId}`, contexts
});
const baselines = await this.repository.listMicrosoftSecurityBaselines();
const eligibleBaselineIds = new Set(baselines.filter(record => record.status === 'eligible').map(record => record.id));
if (!eligibleBaselineIds.size) return { state: 'baseline-required' };
const analyses = await this.repository.listMicrosoftSecurityBaselineAnalyses();
// Exactly one current analysis generation per baseline. An interrupted supersession
// chain would otherwise compare the same baseline twice and leave the saved run
// permanently reported as requiring re-evaluation.
const baselineAnalysisIds = currentBaselineAnalyses(analyses)
.filter(item => eligibleBaselineIds.has(item.analysis.baselineId))
.map(item => item.analysis.id).sort();
if (!baselineAnalysisIds.length) return { state: 'baseline-required' };
const aggregation = await this.snapshots.aggregate(projectId, {
environmentProfileId: profile.id, sourceAnalysisRunIds: [analysis.run.id]
});
const evaluation = await this.evaluations.evaluate(projectId, {
aggregationRunId: aggregation.run.id, baselineAnalysisIds: [...new Set(baselineAnalysisIds)].sort()
});
await this.findings.create(projectId, { evaluationRunId: evaluation.run.id });
return { state: evaluation.run.status === 'complete' ? 'evaluated' : 'review', evaluationRunId: evaluation.run.id };
}
}