189 lines
9.7 KiB
TypeScript
189 lines
9.7 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 { 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[]>();
|
|
|
|
/**
|
|
* Werkzeuguebergreifender Arbeitsfilter. Die Muster entsprechen der fachlichen
|
|
* Abweichung: fehlgeschlagene oder klaerungsbeduerftige Resultate sowie
|
|
* festgestellte Expositionen. Der Filter ist in jedem Profil verfuegbar und
|
|
* bildet den unveraenderlichen Startzustand aller Diagramme.
|
|
*/
|
|
const UNIVERSAL_DEVIATION_PATTERNS = [
|
|
/^fail(?:ed)?$/,
|
|
/^not passed?$/,
|
|
/^nicht bestanden$/,
|
|
/^ioes? (?:found|detected)$/,
|
|
/^matched$/,
|
|
/^detected$/,
|
|
/^open$/,
|
|
/^offen$/,
|
|
/^investigate$/,
|
|
/^warning$/
|
|
];
|
|
|
|
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';
|
|
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 (status === 'deviation' && !filter) {
|
|
return values.some(value => UNIVERSAL_DEVIATION_PATTERNS.some(pattern => matches(pattern, value)));
|
|
}
|
|
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
|
|
}
|
|
};
|
|
}
|