190 lines
11 KiB
TypeScript
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] };
|
|
}
|
|
};
|