Assessor_2.0/src/importers/purple-knight-summary-sourc...

226 lines
9.0 KiB
TypeScript

/**
* 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: <Kategorie>" 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: []
};
}
};