/** * Umgebungskontext aus der Purple-Knight-Zusammenfassung. * * Purple Knight exportiert neben den Indikatorergebnissen eine eigene * Zusammenfassungsdatei aus zwei Spalten: links die Kennzahl, rechts ihr Wert. * Dort stehen genau die Angaben, die kein einzelner Befund traegt - der * Posture Score, die Kategoriewerte, der Forestname, die Zahl der Domaenen und * die Auswertungsbilanz (evaluated, IOEs found, passed, not relevant). * * Ohne diesen Extractor lief die Datei in die allgemeine Tabellenextraktion: * sie erzeugte dort keine Befunde (die Zeilen sind keine Indikatoren) und ihr * Inhalt blieb ausschliesslich als Quelltabelle liegen. Die Kennzahlen waren * damit importiert, aber fuer Auswertung und Darstellung unerreichbar. * * Summary-Zeilen liefern ausschliesslich Kontext. Andere Tabellen derselben * Datei durchlaufen weiterhin den generischen Extractor und das bestaetigte * Tabellenmapping. Ein Summary darf niemals Workbook-Findings verdraengen. */ import type { JsonValue } from '../domain/canonical-model'; import type { EnvironmentSubject, EnvironmentValueType } from '../domain/environment-context'; import type { DecodedSourceDocument, ImportTable, SourceLocator } from '../domain/import-intermediate'; import type { EnvironmentObservationCandidate, SourceExtractor } from '../domain/source-extraction'; import { clean, normalizeNumericValue } from '../shared/text'; import { genericTableSourceExtractor } from './generic-table-source-extractor'; /** Abschnitt "AD category score: " der Zusammenfassung. */ const CATEGORY_SCORE = /^(ad|aad|entra(?:\s*id)?)\s+category\s+score:\s*(.+)$/i; /** Kennzahl mit vorangestelltem Erhebungsbereich, z. B. "AD: Passed". */ const SCOPED_METRIC = /^(ad|aad|entra(?:\s*id)?):\s*(.+)$/i; /** Kopfzeile der Zusammenfassung: "Tool edition" und die eingesetzte Edition. */ const TOOL_EDITION = /^tool\s+edition$/i; /** * Kennzahlen, die diese Datei zweifelsfrei als Purple-Knight-Zusammenfassung * ausweisen. Zwei Treffer sind gefordert, damit eine beliebige zweispaltige * Tabelle mit einem aehnlich benannten Feld nicht faelschlich hier landet. */ const SUMMARY_KEYS = [ /^(?:ad|aad|entra(?:\s*id)?):\s*security\s+posture\s+score$/i, /^(?:ad|aad|entra(?:\s*id)?):\s*ioes?\s+found$/i, /^ad:\s*number\s+of\s+domains$/i, /^ad:\s*forest\s+name$/i, /^entra(?:\s*id)?:\s*tenant$/i, /^(?:ad|aad|entra(?:\s*id)?)\s+category\s+score:/i, /^selected\s+indicators$/i ]; const ENVIRONMENT: EnvironmentSubject = { type: 'active-directory-environment' }; interface SummaryPair { key: string; value: JsonValue; tableIndex: number; rowIndex: number; columnIndex: number; } interface MetricMapping { property: string; label: string; valueType: EnvironmentValueType; subject: EnvironmentSubject; } function assessmentSubject(fileName: string): EnvironmentSubject { return { type: 'purple-knight-assessment', identity: fileName, label: 'Purple Knight assessment' }; } function propertyKey(value: string): string { return value.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'value'; } function scopeKey(scope: string): string { return /^ad$/i.test(scope) ? 'ad' : 'entra'; } /** * Kennzahlen, deren Aussage nicht werkzeugspezifisch ist, werden auf die * gemeinsamen Eigenschaftsnamen abgebildet. Alles andere bleibt bewusst unter * `purple_knight.*`, damit eine native Toolzahl nie unbeschriftet in einer * werkzeuguebergreifenden Kennzahl landet. */ const SHARED_PROPERTIES: Array<[RegExp, MetricMapping]> = [ [/^generated\s+date$/i, { property: 'report.generated_at', label: 'Report generation date', valueType: 'datetime', subject: ENVIRONMENT }], [/^ad:\s*forest\s+name$/i, { property: 'forest.name', label: 'Forest name', valueType: 'string', subject: ENVIRONMENT }], [/^ad:\s*number\s+of\s+domains$/i, { property: 'domain.count', label: 'Number of domains', valueType: 'number', subject: ENVIRONMENT }], [/^entra(?:\s*id)?:\s*tenant$/i, { property: 'entra.tenant_name', label: 'Entra tenant', valueType: 'string', subject: { type: 'entra-tenant' } }] ]; function mappingFor(key: string, fileName: string): MetricMapping { const shared = SHARED_PROPERTIES.find(([pattern]) => pattern.test(key)); if (shared) return shared[1]; const category = CATEGORY_SCORE.exec(key); if (category) { const name = clean(category[2]); return { property: `purple_knight.${scopeKey(category[1])}.category_score.${propertyKey(name)}`, label: name, valueType: 'number', subject: { type: 'security-domain', identity: propertyKey(name), label: name } }; } const scoped = SCOPED_METRIC.exec(key); if (scoped) { return { property: `purple_knight.${scopeKey(scoped[1])}.${propertyKey(scoped[2])}`, label: clean(scoped[2]), valueType: 'string', subject: assessmentSubject(fileName) }; } return { property: `purple_knight.${propertyKey(key)}`, label: clean(key), valueType: 'string', subject: assessmentSubject(fileName) }; } /** * Zahlen werden als Zahl normalisiert, damit Auswertung und Darstellung nicht * erneut parsen muessen. Datumsangaben bleiben Text: die Zusammenfassung nennt * sie ohne Zeitzone, eine stille Umrechnung waere eine Behauptung. */ export function purpleKnightMetricObservation(key: string, value: JsonValue, source: SourceLocator): EnvironmentObservationCandidate { const mapping = mappingFor(key, source.fileName); const numeric = mapping.valueType === 'datetime' ? null : normalizeNumericValue(value); const typed: { normalizedValue: JsonValue; valueType: EnvironmentValueType } = numeric !== null ? { normalizedValue: numeric, valueType: 'number' } : { normalizedValue: value, valueType: value !== null && typeof value === 'object' ? 'object' : mapping.valueType }; return { version: 1, subject: mapping.property === 'entra.tenant_name' && typeof value === 'string' && value.trim() ? { type: 'entra-tenant', identity: value.trim(), label: value.trim() } : mapping.subject, property: mapping.property, label: mapping.label, originalValue: value, ...typed, state: value === null || value === '' ? 'empty' : 'observed', sectionKey: 'purple-knight-assessment-summary', source, attributes: { sourceMetric: key } }; } function observation(pair: SummaryPair, fileName: string): EnvironmentObservationCandidate { return purpleKnightMetricObservation(pair.key, pair.value, { fileName, tableIndex: pair.tableIndex, rowIndex: pair.rowIndex, columnIndex: pair.columnIndex }); } /** Zweispaltige Kennzahlenpaare einer Tabelle, Kopfzeile eingeschlossen. */ function summaryPairs(table: ImportTable): SummaryPair[] { return table.rows.flatMap(row => { const key = clean(row.cells[0]?.decodedValue); if (!key) return []; return [{ key, value: row.cells[1]?.rawValue ?? null, tableIndex: table.index, rowIndex: row.index, columnIndex: 1 }]; }); } function summaryTable(document: DecodedSourceDocument): ImportTable | undefined { if (document.format === 'HTML') return undefined; return document.tables.find(table => { const keys = summaryPairs(table).map(pair => pair.key); return SUMMARY_KEYS.filter(pattern => keys.some(key => pattern.test(key))).length >= 2; }); } export const purpleKnightSummarySourceExtractor: SourceExtractor = { id: 'purple-knight-summary-source-extractor', version: '1.2.0', detect(document) { return summaryTable(document) ? { score: 1, toolId: 'purple-knight', evidence: ['PurpleKnight summary metrics recognized.'] } : { score: 0, evidence: [] }; }, extract(document) { const table = summaryTable(document); if (!table) return { version: 1, candidates: [], diagnostics: [] }; const pairs = summaryPairs(table); const editionRow = pairs.find(pair => TOOL_EDITION.test(pair.key)); const metrics = pairs.filter(pair => pair !== editionRow); const environmentObservations = [ ...(editionRow ? [observation(editionRow, document.file.name)] : []), ...metrics.map(pair => observation(pair, document.file.name)) ]; return { version: 1, // A context table must never consume the finding tables in the same file. // Keep their generic fields/locators; table selection remains the mapping layer's responsibility. candidates: genericTableSourceExtractor.extract({ ...document, tables: document.tables.filter(item => item.index !== table.index) }).candidates, reportSections: [{ version: 1, sectionKey: 'purple-knight-assessment-summary', label: 'Purple Knight assessment summary', ordinal: 0, state: metrics.length ? 'observed' : 'empty', tables: [{ name: 'Assessment summary', sourceTableIndex: table.index, headers: ['Metric', 'Value'], rows: metrics.map(pair => [pair.key, pair.value]) }], source: { fileName: document.file.name, tableIndex: table.index }, attributes: { metricCount: metrics.length } }], environmentObservations, diagnostics: [] }; } };