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(); 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>(); 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 } }; }