Assessor_2.0/src/importers/purple-knight-html-source-e...

190 lines
11 KiB
TypeScript

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, JsonValue>): 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<string, JsonValue>, categoryNames: Map<number, string>): 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, JsonValue>): 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<string, JsonValue>, 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<string, JsonValue>, 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<string, JsonValue>, path: string, categoryNames: Map<number, string>, 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<string, string> = {
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] };
}
};