Assessor_2.0/src/profiles/profile-factory.ts

169 lines
9.1 KiB
TypeScript

import type { DatasetSheet } from '../domain/dataset';
import type { FindingRecord } from '../domain/finding';
import type { ImportProfile, ProfileDetectionContext } from '../domain/import-profile';
import type { ToolProfileDefinition, WeightedPattern } from '../domain/tool-profile-definition';
import { isDeviationRecord } from '../importers/status-normalization';
import { clean, plainTextValue } from '../shared/text';
function matches(pattern: RegExp, value: string): boolean {
pattern.lastIndex = 0;
return pattern.test(value);
}
/**
* Normalisierte Feldwerte eines Datensatzes, wie `matchesStatus` sie zum
* Mustervergleich braucht (klein geschrieben, Sonderzeichen zu Leerzeichen).
*
* Diese Umwandlung ist profilunabhaengig - dieselbe Normalisierung gilt fuer
* jedes Statusvokabular - und wird pro Datensatz gecacht. Ohne Cache lief sie
* bei einem grossen Import mehrfach je Datensatz erneut: einmal je
* Statusfilter-Wert bei der Facettenzaehlung (siehe dynamic-facets.ts) und
* nochmal beim eigentlichen Filtern, bei jedem Rendern neu. Bei einem
* CIS-CAT-Import mit mehreren tausend Datensaetzen und rund zwanzig Feldern
* je Datensatz war genau das der eigentliche Einfrierer beim Wechsel in den
* Arbeitsbereich - nicht das Rendern selbst.
*/
const normalizedFieldValuesCache = new WeakMap<FindingRecord, string[]>();
function normalizedFieldValues(record: FindingRecord): string[] {
let cached = normalizedFieldValuesCache.get(record);
if (!cached) {
cached = record.fields.map(field => plainTextValue(field.value).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim());
normalizedFieldValuesCache.set(record, cached);
}
return cached;
}
function matchesGroupPolicyStatus(record: FindingRecord, actualStatus: string | undefined, status: string): boolean {
const diagnosticPresent = (label: RegExp) => record.fields.some(field => label.test(field.label.trim())
&& !['', 'no', 'none', 'false', '0'].includes(plainTextValue(field.value).trim().toLowerCase()));
if (status === 'passed') return actualStatus === 'pass' || actualStatus === 'passed';
if (status === 'not-configured') return actualStatus === 'not configured';
// A retained setting with no applicable baseline rule; never an absent recommendation.
if (status === 'no-baseline-rule') return actualStatus === 'no baseline rule';
// A delivery mechanism without a comparable technical policy identity.
if (status === 'not-comparable') return actualStatus === 'not comparable';
if (status === 'conflicts') return actualStatus === 'conflicts' || diagnosticPresent(/^conflict$/i);
if (status === 'duplicates') return actualStatus === 'duplicates' || diagnosticPresent(/^duplicates$/i);
if (status !== 'deviation') return false;
// Follow the authoritative verdict, not the displayed value. Missing required
// registry presence is Fail; ordinary missing recommendations already carry
// the separate, score-neutral Not configured verdict.
return ['fail', 'failed', 'misconfigured'].includes(actualStatus ?? '');
}
function firstMatchingHeader(sheet: DatasetSheet | undefined, patterns: RegExp[]): string {
return sheet?.analysis.headers.find(header => patterns.some(pattern => matches(pattern, clean(header)))) ?? '';
}
function combinedPattern(patterns: RegExp[], fallback: RegExp): RegExp {
return patterns.length ? new RegExp(patterns.map(pattern => `(?:${pattern.source})`).join('|'), 'i') : fallback;
}
function sourceValues(context: ProfileDetectionContext, scope: ToolProfileDefinition['sourceSignals'][number]['scope']): string[] {
if (scope === 'file-name') return [context.fileName];
if (scope === 'raw-text') return [context.rawText.slice(0, 250000)];
if (scope === 'table-name') return context.sheets.map(sheet => sheet.name);
if (scope === 'header') return context.sheets.flatMap(sheet => sheet.analysis.headers);
return context.sheets.flatMap(sheet => sheet.rows.slice(0, 80).flat());
}
export function profileDetectionScore(definition: ToolProfileDefinition, context: ProfileDetectionContext): number {
if (definition.fallback) return 1;
const score = definition.sourceSignals.reduce((sum, signal) =>
sourceValues(context, signal.scope).some(value => matches(signal.pattern, value)) ? sum + signal.contribution : sum, 0);
return Math.round(Math.min(1, score) * 1000);
}
function patternScore(value: string, patterns: WeightedPattern[]): number {
return patterns.reduce((sum, rule) => matches(rule.pattern, value) ? sum + rule.contribution : sum, 0);
}
export function profileSheetScore(definition: ToolProfileDefinition, sheet: DatasetSheet): number {
const name = clean(sheet.name);
const headerScore = definition.fieldRules.reduce((sum, rule) =>
rule.headers.some(headerRule => sheet.analysis.headers.some(header => matches(headerRule.pattern, clean(header)))) ? sum + 12 : sum, 0);
return patternScore(name, definition.tableRules.preferredNames) * 1000
- patternScore(name, definition.tableRules.discouragedNames) * 1000
+ headerScore
+ Math.min(sheet.analysis.records.length, 500) / 10
- (sheet.hidden ? 100 : 0);
}
export function createImportProfile(definition: ToolProfileDefinition): ImportProfile {
// Profile identity is part of the cache lifetime; reviews never mutate source records.
const statusCache = new WeakMap<FindingRecord, Map<string, boolean>>();
const matchesRecordStatus = (record: FindingRecord, status: string): boolean => {
const actualStatus = record.fields.find(field => /^status$/i.test(field.label.trim()))?.value.trim().toLowerCase();
const policyRecord = record.fields.some(field =>
(/^category$/i.test(field.label.trim()) && /^gpos$/i.test(plainTextValue(field.value).trim()))
|| (/^(?:source|quelle):\s*scan$/i.test(field.label.trim()) && /^gpos(?:\s|·|$)/i.test(plainTextValue(field.value).trim())));
if (policyRecord && ['deviation', 'passed', 'not-configured', 'no-baseline-rule', 'not-comparable',
'conflicts', 'duplicates'].includes(status)) {
return matchesGroupPolicyStatus(record, actualStatus, status);
}
if (['not-configured', 'conflicts', 'duplicates'].includes(status)) {
return actualStatus === status.replace('-', ' ');
}
if (status === 'no-baseline-rule') return actualStatus === 'no baseline rule';
if (status === 'not-comparable') return actualStatus === 'not comparable';
if (status === 'deviation') return isDeviationRecord(record, definition.id);
const filter = definition.presentation.statusFilters.find(item => item.value === status);
const statusHeaders = definition.presentation.statusHeaders;
const preferredStatus = statusHeaders?.map(pattern => record.fields.find(field => field.value.trim() && matches(pattern, field.label.trim())))
.find(Boolean);
const values = statusHeaders?.length
? preferredStatus ? [plainTextValue(preferredStatus.value).trim().toLowerCase()] : []
: normalizedFieldValues(record);
if (!filter) return true;
return values.some(value => filter.patterns.some(pattern => matches(pattern, value)));
};
const declaredStatusFilters = definition.presentation.statusFilters.map(({ value, label }) => ({ value, label }));
const presentationStatusFilters = declaredStatusFilters.some(filter => filter.value === 'deviation')
? declaredStatusFilters
: [
...declaredStatusFilters.filter(filter => filter.value === 'all'),
{ value: 'deviation', label: 'Deviation' },
...declaredStatusFilters.filter(filter => filter.value !== 'all')
];
return {
id: definition.id,
name: definition.name,
version: definition.version,
definition,
detect: context => profileDetectionScore(definition, context),
prioritizeSheets: sheets => definition.fallback ? sheets : [...sheets].sort((left, right) =>
profileSheetScore(definition, right) - profileSheetScore(definition, left)
|| (left.hidden ?? 0) - (right.hidden ?? 0)
|| (left.workbookIndex ?? 0) - (right.workbookIndex ?? 0)),
defaults: sheet => ({
titleColumn: firstMatchingHeader(sheet, definition.presentation.titleHeaders),
// Keine werkzeugeigene Punktesortierung als Vorgabe: die Reihenfolge
// kommt aus der kanonischen Severity (kritischste zuerst) und ist damit
// ueber alle Berichte hinweg dieselbe.
sortColumn: 'all',
sortDirection: definition.presentation.sortDirection,
statusFilter: 'deviation'
}),
matchesStatus: (record, status) => {
if (status === 'all') return true;
let statuses = statusCache.get(record);
if (!statuses) { statuses = new Map(); statusCache.set(record, statuses); }
const cached = statuses.get(status);
if (cached !== undefined) return cached;
const result = matchesRecordStatus(record, status);
// Only the finite declared vocabulary is retained, never arbitrary query text.
if (status === 'deviation' || definition.presentation.statusFilters.some(filter => filter.value === status)) {
statuses.set(status, result);
}
return result;
},
presentation: {
severityFieldPattern: combinedPattern(definition.presentation.severityHeaders, /$a/),
titleFieldPattern: combinedPattern(definition.presentation.titleHeaders, /$a/),
statusFilters: presentationStatusFilters
}
};
}