import type { CanonicalFindingStatus, CanonicalOutcome, CanonicalTechnicalExecutionState } from '../domain/canonical-model'; import type { FindingRecord } from '../domain/finding'; import { outcomeFromLegacyStatus, technicalStateFromLegacyStatus } from '../domain/unified-result'; import { toolProfileDefinitions } from '../profiles/profile-definition-catalog'; import { clean } from '../shared/text'; export interface StatusDimensions { legacy: CanonicalFindingStatus; outcome: CanonicalOutcome; technical: CanonicalTechnicalExecutionState; } const UNKNOWN_DIMENSIONS: StatusDimensions = { legacy: 'unknown', outcome: 'unknown', technical: 'unknown' }; /** * Werkzeugunabhaengige Rueckfallregeln fuer Quellen ohne (oder mit fuer diesen * Wert unvollstaendigem) deklariertem Profil-Mapping. * * Die Reihenfolge ist bedeutungstragend: verneinende und mehrwortige * Formulierungen zuerst, damit "failed to run" nicht als fachliches Scheitern * und "not passed" nicht als Bestehen gelesen wird. Die beiden unspezifischen * Teilstring-Regeln am Ende bilden das frueher einzige Verhalten ab und bleiben * erhalten, damit bereits funktionierende Quellformate nicht regressieren. * * `outcome` wird nur gesetzt, wo es bewusst von der Ableitung aus dem * Legacy-Status abweicht (siehe `matched|detected`: ein Treffer ist ein * Hinweis, kein bestandener oder fehlgeschlagener Test). */ const fallbackRules: Array<{ pattern: RegExp; legacy: CanonicalFindingStatus; outcome?: CanonicalOutcome }> = [ { pattern: /^not configured$/, legacy: 'open', outcome: 'informational' }, // A retained setting without an applicable baseline rule is evidence, not a deviation. { pattern: /^no baseline rule$/, legacy: 'open', outcome: 'informational' }, // A Group Policy Preference is retained evidence, never a baseline verdict. { pattern: /^not comparable$/, legacy: 'open', outcome: 'informational' }, { pattern: /^misconfigured$/, legacy: 'failed' }, { pattern: /failed\s*to\s*run|execution.?error|technical.?error/, legacy: 'error' }, { pattern: /not\s*(?:applicable|relevant)|irrelevant|nicht\s*(?:anwendbar|relevant)/, legacy: 'not-applicable' }, { pattern: /not\s*(?:run|selected|executed)|nicht\s*(?:ausgef(?:ue|ü)hrt|ausgew(?:ae|ä)hlt)|skipped|cancell?ed|abgebrochen/, legacy: 'not-run' }, { pattern: /not\s*pass(?:ed)?|nicht\s*bestanden/, legacy: 'failed' }, { pattern: /ioe(?:s)?\s*(?:found|detected)|indicator\s*of\s*exposure/, legacy: 'failed' }, { pattern: /resolved|closed|behoben/, legacy: 'resolved' }, { pattern: /^pass(?:ed)?$|bestanden/, legacy: 'passed' }, { pattern: /^fail(?:ed)?$|fehlgeschlagen/, legacy: 'failed' }, { pattern: /matched|detected|gefunden/, legacy: 'open', outcome: 'warning' }, { pattern: /error|fehler/, legacy: 'error' }, { pattern: /manual/, legacy: 'manual' }, { pattern: /investigate|pr(?:ue|ü)fen/, legacy: 'investigate' }, { pattern: /^conflicts?$|^duplicates?$/, legacy: 'open', outcome: 'warning' }, { pattern: /open|offen/, legacy: 'open' }, { pattern: /pass/, legacy: 'passed' }, { pattern: /fail/, legacy: 'failed' } ]; const definitionsByToolId = new Map(toolProfileDefinitions.map(definition => [definition.id, definition])); function dimensionsFor(legacy: CanonicalFindingStatus, outcome?: CanonicalOutcome): StatusDimensions { return { legacy, outcome: outcome ?? outcomeFromLegacyStatus(legacy), technical: technicalStateFromLegacyStatus(legacy) }; } /** * Wendet zuerst das im Werkzeugprofil deklarierte Status-Mapping an * (ToolProfileDefinition.normalization.status, z. B. commonStatusMappings mit * "IOEs found" -> failed fuer Purple Knight) und faellt nur dann auf die * generischen Regeln zurueck, wenn das Profil den Quellwert nicht kennt. * * Zuvor war dieses deklarierte Mapping vollstaendig unbenutzt: die Pipeline * besass eine eigene, unvollstaendige Regexliste ohne Purple-Knight-Vokabular, * wodurch dessen Befunde ("IOEs found", "Not Relevant", "Not selected", * "Canceled") als unknown statt fail/not_applicable/not_run in der * Result-Tabelle landeten - und die echten Expositionen damit auch nie als * is_ioe markiert wurden. */ export function normalizeStatusDimensionsFor(toolId: string | undefined, value: string): StatusDimensions { const cleaned = clean(value); if (!cleaned) return UNKNOWN_DIMENSIONS; const declared = toolId ? definitionsByToolId.get(toolId)?.normalization.status : undefined; const declaredMatch = declared?.find(mapping => mapping.patterns.some(pattern => pattern.test(cleaned))); if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue); const fallback = fallbackRules.find(rule => rule.pattern.test(cleaned.toLocaleLowerCase('de-DE'))); return fallback ? dimensionsFor(fallback.legacy, fallback.outcome) : UNKNOWN_DIMENSIONS; } export function normalizeStatusFor(toolId: string | undefined, value: string): CanonicalFindingStatus { return normalizeStatusDimensionsFor(toolId, value).legacy; } const toolIdByName = new Map(toolProfileDefinitions.flatMap(definition => [definition.name, ...(definition.runNames ?? [])].map(name => [name.toLocaleLowerCase('en-US'), definition.id] as const))); /** Provenienzspalte der Projektaggregation: " · ". */ const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i; /** * Werkzeug-ID eines einzelnen Datensatzes. * * Die Projektaggregation in Work mischt die Ergebnisse aller Werkzeuge eines * Mandanten in einem Blatt und traegt deshalb die Profil-ID `generic`. Wird der * Status damit normalisiert, greifen die generischen Rueckfallregeln statt des * deklarierten Werkzeugmappings - PingCastles `Matched` gilt dort als blosser * Hinweis (`warning`) statt als erkannte Exposition (`fail`), und Work weicht * von der Result DB ab. Die Herkunft je Datensatz aufzuloesen verhindert genau * das. */ export function toolIdForRecord(record: FindingRecord, fallbackToolId?: string): string | undefined { const field = record.fields.find(candidate => SCAN_PROVENANCE_LABEL.test(candidate.label.trim()) && candidate.value); const toolName = clean(field?.value.split(/\s*(?:\u00c2\u00b7|\u00b7)\s*/)[0] ?? '').toLocaleLowerCase('en-US'); return toolName === 'gpos' ? 'group-policy' : toolIdByName.get(toolName) ?? fallbackToolId; } /** * Outcome eines rohen Statusfeldes, wie es die Arbeitsansichten anzeigen. * * Work arbeitet auf der Rohprojektion des Datasets und kennt den kanonischen * Outcome eines Vorkommens nicht. Ohne diese Ableitung sehen dort `Passed`, * `Failed` und `Investigate` identisch aus, obwohl sie fachlich das Gegenteil * voneinander bedeuten. Die Ableitung verwendet dieselbe werkzeuggebundene * Normalisierung wie der Importpfad, damit Work und Result DB denselben Wert * zeigen. */ export function outcomeForStatusValue( toolId: string | undefined, value: string | undefined ): CanonicalOutcome | undefined { if (!clean(value ?? '')) return undefined; return normalizeStatusDimensionsFor(toolId, value as string).outcome; }