135 lines
8.6 KiB
TypeScript
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();
|
|
}
|