Assessor_2.0/src/assessment-context/context-run.ts

135 lines
8.6 KiB
TypeScript

import type { AssessorRepository } from '../persistence/assessor-repository';
import { queryAllUnifiedResults } from '../exports/unified-result-jsonl';
import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace';
import type { FindingRecord } from '../domain/finding';
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG, type AssessmentContextCatalog } from './context-catalog';
import { buildAssessmentContext, type AssessmentContext } from './context-engine';
import { contextGapReport } from './context-gaps';
/** The only ASSESSOR data access of the context engine: read methods only. */
export interface ContextReadPort extends Pick<AssessorRepository, 'queryUnifiedResults' | 'listGpoPolicyAnalyses' | 'listReferenceRuns' | 'getEnvironmentSnapshot' | 'listPolicyEvaluations'> {
currentRevision(): Promise<number>;
/** Microsoft baseline analyses: template names and the Work records' baseline guidance (2026-10-01). */
listMicrosoftSecurityBaselineAnalyses?: AssessorRepository['listMicrosoftSecurityBaselineAnalyses'];
/** Assessments and canonical models: read to build the same Work records Raw Data shows (2026-10-01). */
listAssessments?: AssessorRepository['listAssessments'];
loadAssessmentModel?: AssessorRepository['loadAssessmentModel'];
}
export interface AssessmentContextScope {
projectId: string;
assessmentIds: string[];
/** Timeslot restriction of scanner results, exactly as Work applies it. */
scanRunIds?: string[];
generatedAt: string;
}
export class ContextStateChangedError extends Error {
constructor() {
super('ASSESSOR data changed repeatedly while the context was read. Please retry.');
this.name = 'ContextStateChangedError';
}
}
const fieldOf = (record: FindingRecord, label: string) =>
record.fields.find(field => field.label.trim().toLowerCase() === label)?.value?.trim() ?? '';
/**
* Every field Raw Data shows for a Work record (user decision 2026-10-01). The records come from the same workspace
* loader Work and the Report Engine use, read-only; scanner records are keyed by finding occurrence, GPO records by
* scope and technical identity. Nothing is shortened.
*/
async function rawDataRecords(port: ContextReadPort, scope: AssessmentContextScope) {
if (!port.listAssessments || !port.loadAssessmentModel) return { findings: [], settings: [] };
const workspace = await loadCanonicalProjectWorkspace({
listProjectAssessments: projectId => port.listAssessments!(projectId),
loadAssessmentModel: async assessmentId => {
const model = await port.loadAssessmentModel!(assessmentId);
if (!model) throw new Error(`Assessment ${assessmentId} not found.`);
return model;
},
queryUnifiedResults: query => port.queryUnifiedResults(query),
listPolicyEvaluations: async projectId => ({ records: await port.listPolicyEvaluations(projectId) }),
...(port.listMicrosoftSecurityBaselineAnalyses ? {
listMicrosoftSecurityBaselineAnalyses: async (baselineId: string) => ({ records: await port.listMicrosoftSecurityBaselineAnalyses!(baselineId) })
} : {})
}, scope.projectId, scope.scanRunIds, scope.assessmentIds);
const records = [...new Map((workspace.dataset?.sheets ?? []).flatMap(sheet => sheet.analysis.records).map(record => [record.id, record])).values()];
const fields = (record: FindingRecord) => record.fields.filter(field => field.value).map(field => ({ label: field.label, value: field.value }));
return {
findings: records.filter(record => record.findingOccurrenceId).map(record => ({ occurrenceId: record.findingOccurrenceId!, fields: fields(record) })),
settings: records.filter(record => !record.findingOccurrenceId && fieldOf(record, 'technical identity') && fieldOf(record, 'scope'))
.map(record => ({ identityKey: fieldOf(record, 'technical identity'), scope: fieldOf(record, 'scope'), fields: fields(record) }))
};
}
/**
* Reads the same current GPO observations and scanner findings Work reads and derives the context from one committed
* state: a revision change during the read repeats it instead of mixing states. Nothing is written.
*/
export async function createAssessmentContext(
port: ContextReadPort,
scope: AssessmentContextScope,
catalog: AssessmentContextCatalog = RELEASED_ASSESSMENT_CONTEXT_CATALOG,
attempts = 3
): Promise<AssessmentContext> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const before = await port.currentRevision();
const [observations, scannerFindings, evaluations, analyses, referenceRuns, storedEvaluations] = await Promise.all([
queryAllUnifiedResults(query => port.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: scope.assessmentIds,
sourceKind: ['policy-observation']
}),
queryAllUnifiedResults(query => port.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: scope.assessmentIds,
scanRunIds: scope.scanRunIds?.length ? scope.scanRunIds : undefined, sourceKind: ['scanner-finding']
}),
queryAllUnifiedResults(query => port.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: scope.assessmentIds, sourceKind: ['policy-evaluation']
}),
port.listGpoPolicyAnalyses(scope.projectId),
port.listReferenceRuns(scope.projectId),
port.listPolicyEvaluations(scope.projectId)
]);
// Section titles and counts of the CIS benchmark come only from the scan reports of the CIS findings in scope.
const cisRunIds = [...new Set(scannerFindings.filter(item => item.sourceTool === 'cis-cat' && item.scanRunId).map(item => item.scanRunId!))].sort();
const cisReportSections = cisRunIds.length
? (await port.getEnvironmentSnapshot({ projectId: scope.projectId, scanRunIds: cisRunIds })).sections
.filter(section => section.sectionKey === 'cis-cat-section-scores')
: [];
const input = {
projectId: scope.projectId, assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds, generatedAt: scope.generatedAt,
repositoryRevision: before, observations, evaluations,
// Technical targets of evaluated settings, also for key-level identities without an own observation.
identityTargets: storedEvaluations.flatMap(run => run.results.map(result => ({ id: result.identity.id, scope: result.identity.scope, target: result.identity.target }))),
analyses, scannerFindings, referenceRuns, cisReportSections, catalog
};
let context = buildAssessmentContext(input);
// Baseline template names and everything Raw Data shows (user decisions 2026-10-01) are large reads. They run only
// when deviations remain that the catalogue does not place, one after another and after the reads above: the
// read-only pool is small, and parallel or unconditional loading starves it. A failing read never takes the
// context down; the result then rests on the fields read above.
if (context.recognitions.length || context.coverage.uncataloguedWithoutRecognition) {
// Only the baselines the still unplaced GPO settings were compared with, one after another.
const pendingIdentities = new Set(context.evaluations.filter(item => item.matchedBy !== 'setting' && item.verdict === 'deviation').map(item => item.identityKey));
const baselineIds = [...new Set(evaluations.filter(item => item.policyIdentityKey && pendingIdentities.has(item.policyIdentityKey) && item.policyBaselineId)
.map(item => item.policyBaselineId!))].sort();
const baselineAnalyses = [];
for (const baselineId of port.listMicrosoftSecurityBaselineAnalyses ? baselineIds : []) {
baselineAnalyses.push(...(await port.listMicrosoftSecurityBaselineAnalyses!(baselineId).catch(() => [])));
}
const baselineTemplates = baselineAnalyses.flatMap(stored => stored.analysis.settings.flatMap(setting =>
setting.identity.status === 'resolved' && setting.template?.names.length
? [{ identityKey: setting.identity.identity.id, names: setting.template.names.map(name => name.value) }] : []));
const rawData = await rawDataRecords(port, scope).catch(() => undefined);
context = buildAssessmentContext({ ...input, baselineTemplates,
...(rawData ? { recordFields: rawData.findings, settingRecordFields: rawData.settings } : {}) });
context = { ...context, rawDataRead: Boolean(rawData) };
}
if (await port.currentRevision() !== before) continue;
// The gap list is derived in the backend with the same catalogue; the UI only reads it.
return { ...context, gapReport: contextGapReport(context, catalog) };
}
throw new ContextStateChangedError();
}