226 lines
9.0 KiB
TypeScript
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: []
|
|
};
|
|
}
|
|
};
|