import type { JsonValue } from '../domain/canonical-model'; import type { FindingCandidate, SourceExtractor, SourceFieldCandidate, SupportingTableCandidate } from '../domain/source-extraction'; import { cellText, plainTextValue } from '../shared/text'; import { isJsonObject } from './embedded-report-json'; import { purpleKnightPayload } from './purple-knight-payload'; import { purpleKnightReportContext, purpleKnightStatus } from './purple-knight-report-context'; const EXCEL_HEADERS = [ 'ShortName', 'Name', 'SI version', 'Target', 'Status', 'Runtime', 'Score', 'Severity', 'Weight', 'Category', 'MITRE ATT&CK_1', 'MITRE ATT&CK_2', 'MITRE ATT&CK_3', 'MITRE ATT&CK_4', 'ANSSI_1', 'ANSSI_2', 'ANSSI_3', 'ANSSI_4', 'MITRE D3FEND_1', 'MITRE D3FEND_2', 'Description', 'Likelihood of compromise', 'Result message', 'Number of results', 'Result', 'Remediation' ] as const; function field( fileName: string, path: string, key: string, label: string, value: JsonValue, decodedValue = cellText(value), columnIndex?: number ): SourceFieldCandidate { return { key, label, originalValue: value, decodedValue, source: { fileName, objectPath: path, columnIndex } }; } function target(indicator: Record): string { const targets = Array.isArray(indicator.Targets) ? indicator.Targets.map(cellText).filter(Boolean) : []; return targets.includes('AAD') && targets.includes('AD') ? 'Hybrid (AD)' : targets.join(', '); } function category(indicator: Record, categoryNames: Map): string { const name = typeof indicator.CategoryID === 'number' ? categoryNames.get(indicator.CategoryID) ?? '' : ''; return target(indicator) === 'Hybrid (AD)' && name === 'Hybrid' ? 'Hybrid (AD)' : name || cellText(indicator.CategoryID); } function runtime(indicator: Record): string { if (typeof indicator.StartTime !== 'string' || typeof indicator.CompletionTime !== 'string') return ''; const ticks = (value: string): bigint | undefined => { const match = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/); if (!match) return undefined; const seconds = Date.parse(`${match[1]}Z`); if (!Number.isFinite(seconds)) return undefined; const fraction = BigInt((match[2] ?? '').slice(0, 7).padEnd(7, '0')); return BigInt(seconds) * 10_000n + fraction; }; const start = ticks(indicator.StartTime); const completion = ticks(indicator.CompletionTime); if (start === undefined || completion === undefined || completion < start) return ''; const totalCentiseconds = Number((completion - start) / 100_000n); const hours = Math.floor(totalCentiseconds / 360_000); const minutes = Math.floor(totalCentiseconds / 6_000) % 60; const seconds = Math.floor(totalCentiseconds / 100) % 60; const centiseconds = totalCentiseconds % 100; return [hours, minutes, seconds, centiseconds].map(value => String(value).padStart(2, '0')).join(':'); } function frameworkValues(indicator: Record, frameworkName: string, count: number): JsonValue[] { const frameworks = Array.isArray(indicator.SecurityFrameworks) ? indicator.SecurityFrameworks : []; const tags = frameworks.flatMap(item => isJsonObject(item) && item.Name === frameworkName && Array.isArray(item.Tags) ? item.Tags : []); return Array.from({ length: count }, (_, index) => tags[index] ?? null); } function reportTable( fileName: string, item: Record, path: string ): SupportingTableCandidate | undefined { const indicator = isJsonObject(item.ResIndicator) ? item.ResIndicator : {}; const name = typeof indicator.ShortName === 'string' ? indicator.ShortName.trim() : ''; const sourceRows = Array.isArray(item.IndicatorReportObjects) ? item.IndicatorReportObjects : []; const rows = sourceRows.filter(Array.isArray); if (!name || !rows.length) return undefined; const headers = rows[0].map((cell, index) => { const value = cellText(cell); const separator = value.indexOf(':'); return separator > 0 ? value.slice(0, separator).trim() : `Column ${index + 1}`; }); return { version: 1, name, headers, rows: rows.map(row => headers.map((header, index) => { const value = typeof row[index] === 'string' ? row[index] as string : cellText(row[index]); const prefix = `${header}:`; const unprefixed = value.startsWith(prefix) ? value.slice(prefix.length).replace(/^ /, '') : value; return unprefixed; })), source: { fileName, objectPath: `${path}.IndicatorReportObjects` }, attributes: { kind: 'finding-result', findingReference: name } }; } function indicatorCandidate( fileName: string, item: Record, path: string, categoryNames: Map, index: number ): FindingCandidate { const indicator = isJsonObject(item.ResIndicator) ? item.ResIndicator : {}; const execution = isJsonObject(item.ExecutionResult) ? item.ExecutionResult : {}; const hasResults = Array.isArray(item.IndicatorReportObjects) && item.IndicatorReportObjects.length > 0; const frameworkTags = [ ...frameworkValues(indicator, 'MITRE ATT&CK', 4), ...frameworkValues(indicator, 'ANSSI', 4), ...frameworkValues(indicator, 'MITRE D3FEND', 2) ]; const values: Array<{ key: string; value: JsonValue; decoded?: string }> = [ { key: 'ShortName', value: indicator.ShortName ?? null }, { key: 'Name', value: indicator.Name ?? null }, { key: 'Version', value: indicator.Version ?? null }, { key: 'Targets', value: indicator.Targets ?? null, decoded: target(indicator) }, { key: 'Status', value: item, decoded: purpleKnightStatus(indicator, execution) }, { key: 'Runtime', value: indicator, decoded: runtime(indicator) }, { key: 'Score', value: execution.Score ?? null }, { key: 'Severity', value: indicator.Severity ?? null }, { key: 'Weight', value: indicator.Weight ?? null }, { key: 'CategoryID', value: indicator, decoded: category(indicator, categoryNames) }, ...frameworkTags.map((value, index) => ({ key: `Framework_${index + 1}`, value })), { key: 'Description', value: indicator.Description ?? null, decoded: plainTextValue(indicator.Description) }, { key: 'LikelihoodOfCompromise', value: indicator.LikelihoodOfCompromise ?? null, decoded: plainTextValue(indicator.LikelihoodOfCompromise) }, { key: 'ResultMessage', value: execution.ResultMessage ?? null }, { key: 'TotalResultsCount', value: item.TotalResultsCount ?? null }, { key: 'Result', value: item.IndicatorReportObjects ?? null, decoded: hasResults ? cellText(indicator.ShortName) : '' }, { key: 'Remediation', value: execution.Remediation ?? null, decoded: plainTextValue(execution.Remediation) } ]; const directPath: Record = { Status: path, Runtime: `${path}.ResIndicator`, CategoryID: `${path}.ResIndicator`, Score: `${path}.ExecutionResult.Score`, ResultMessage: `${path}.ExecutionResult.ResultMessage`, Remediation: `${path}.ExecutionResult.Remediation`, TotalResultsCount: `${path}.TotalResultsCount`, Result: `${path}.IndicatorReportObjects` }; const frameworkPaths = ['MITRE ATT&CK', 'ANSSI', 'MITRE D3FEND'].flatMap((name, group) => { const all = Array.isArray(indicator.SecurityFrameworks) ? indicator.SecurityFrameworks : []; const paths = all.flatMap((entry, frameworkIndex) => isJsonObject(entry) && entry.Name === name && Array.isArray(entry.Tags) ? entry.Tags.map((_, tagIndex) => `${path}.ResIndicator.SecurityFrameworks[${frameworkIndex}].Tags[${tagIndex}]`) : []); return Array.from({ length: group === 2 ? 2 : 4 }, (_, index) => paths[index]); }); const fields = values.map((value, columnIndex) => { const framework = /^Framework_(\d+)$/.exec(value.key); const objectPath = framework ? frameworkPaths[Number(framework[1]) - 1] : directPath[value.key] ?? `${path}.ResIndicator.${value.key}`; const result = field(fileName, objectPath ?? path, value.key, EXCEL_HEADERS[columnIndex], value.value, value.decoded ?? cellText(value.value), columnIndex); // Absent compatibility columns have no fictitious native location. An // explicitly null native field still retains its exact source path. if (value.value === null) { const present = framework ? Boolean(frameworkPaths[Number(framework[1]) - 1]) : value.key === 'Result' ? Object.hasOwn(item, 'IndicatorReportObjects') : value.key === 'TotalResultsCount' ? Object.hasOwn(item, value.key) : ['Score', 'ResultMessage', 'Remediation'].includes(value.key) ? Object.hasOwn(execution, value.key) : Object.hasOwn(indicator, value.key); if (!present) result.source.objectPath = undefined; } return result; }); // Preserve fields beyond the compatibility column set, including complete // framework arrays and future properties. Namespaced labels avoid remapping. const retained = new Set(fields.map(value => value.source.objectPath)); for (const [parent, object] of [['ResIndicator', indicator], ['ExecutionResult', execution]] as const) { for (const [key, value] of Object.entries(object)) { const objectPath = `${path}.${parent}[${JSON.stringify(key)}]`; if (retained.has(`${path}.${parent}.${key}`)) continue; fields.push(field(fileName, objectPath, `source-${parent}-${key}`, `Source ${parent}.${key}`, value)); } } for (const [key, value] of Object.entries(item)) { if (['ResIndicator', 'ExecutionResult', 'IndicatorReportObjects', 'TotalResultsCount'].includes(key)) continue; fields.push(field(fileName, `${path}[${JSON.stringify(key)}]`, `source-${key}`, `Source ${key}`, value)); } return { version: 1, id: `purple-knight-indicator-${index}`, source: { fileName, objectPath: path }, fields }; } export const purpleKnightHtmlSourceExtractor: SourceExtractor = { id: 'purple-knight-html-source-extractor', version: '1.2.0', detect(document) { const payload = purpleKnightPayload(document); return payload.recognized ? { score: 1, toolId: 'purple-knight', evidence: [`Sicher eingebettetes PurpleKnight-Reportmodell mit ${payload.indicators.length} Indikatoren erkannt.`] } : { score: 0, evidence: [] }; }, extract(document) { const payload = purpleKnightPayload(document); const candidates = payload.indicators.map((item, index) => indicatorCandidate(document.file.name, item.value, item.path, payload.categoryNames, index)); const supportingTables = payload.indicators.flatMap(item => { const table = reportTable(document.file.name, item.value, item.path); return table ? [table] : []; }); const context = payload.root ? purpleKnightReportContext(document.file.name, payload.root, payload.indicators) : { reportSections: [], environmentObservations: [], diagnostics: [] }; return { version: 1, candidates, supportingTables, ...context, diagnostics: [...payload.diagnostics, ...context.diagnostics] }; } };