252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
import type { CanonicalSeverity, JsonValue } from './canonical-model';
|
|
|
|
export const DEFENDER_FOR_IOT_ALERTS_PROFILE_ID = 'defender-for-iot-alerts';
|
|
|
|
/** Columns of the native Defender for IoT OT sensor alert export. */
|
|
export const DEFENDER_ALERT_HEADERS = [
|
|
'Id', 'Title', 'First Detection', 'Last Detection', 'Last Activity', 'Severity',
|
|
'Description', 'Acknowledged', 'Additional Information', 'Source Address', 'Destination Address'
|
|
] as const;
|
|
|
|
/**
|
|
* The four OT sensor alert severities in their native order, mapped one to one
|
|
* onto the canonical scale (user decision 2026-09-30). The native value stays
|
|
* retained next to the normalized one.
|
|
*/
|
|
export const DEFENDER_ALERT_SEVERITIES: ReadonlyArray<{ native: string; normalized: CanonicalSeverity; label: string }> = [
|
|
{ native: 'Critical', normalized: 'critical', label: 'Critical' },
|
|
{ native: 'Major', normalized: 'high', label: 'High' },
|
|
{ native: 'Minor', normalized: 'medium', label: 'Medium' },
|
|
{ native: 'Warning', normalized: 'low', label: 'Low' }
|
|
];
|
|
|
|
export type DefenderAlertStatus = 'Unacknowledged' | 'Acknowledged' | 'Acknowledgement unknown';
|
|
|
|
export interface DefenderAlertSourceRow {
|
|
tableIndex: number;
|
|
rowIndex: number;
|
|
/** Values by source header, exactly as read from the row. */
|
|
fields: Record<string, JsonValue>;
|
|
/** Original single-cell line when the workbook wraps each CSV line in one cell. */
|
|
rawLine?: string;
|
|
}
|
|
|
|
export interface DefenderAlert {
|
|
id: string;
|
|
title: string;
|
|
severity: string;
|
|
firstDetection: string;
|
|
lastDetection: string;
|
|
lastActivity: string;
|
|
description: string;
|
|
acknowledged: string;
|
|
sourceAddress: string;
|
|
destinationAddress: string;
|
|
/** One entry per non-empty source row value, in source order. */
|
|
additionalInformation: string[];
|
|
rows: DefenderAlertSourceRow[];
|
|
}
|
|
|
|
export interface DefenderAlertType {
|
|
title: string;
|
|
/** Native severity shared by the alerts, or the highest one when they disagree. */
|
|
severity: string;
|
|
alerts: DefenderAlert[];
|
|
unacknowledgedCount: number;
|
|
acknowledgedCount: number;
|
|
unknownAcknowledgementCount: number;
|
|
status: DefenderAlertStatus;
|
|
}
|
|
|
|
export interface DefenderAlertDiagnostic {
|
|
severity: 'warning' | 'error';
|
|
code: string;
|
|
message: string;
|
|
tableIndex?: number;
|
|
rowIndex?: number;
|
|
details?: JsonValue;
|
|
}
|
|
|
|
export interface DefenderAlertModel {
|
|
types: DefenderAlertType[];
|
|
alertCount: number;
|
|
rowCount: number;
|
|
diagnostics: DefenderAlertDiagnostic[];
|
|
}
|
|
|
|
const normalizedHeader = (value: string) => value.trim().toLocaleLowerCase('en-US');
|
|
const requiredHeaders = DEFENDER_ALERT_HEADERS.map(normalizedHeader);
|
|
|
|
const DIFFERING_PART = '…';
|
|
|
|
function commonTokens(template: Array<string | null>, tokens: string[]): Array<string | null> {
|
|
// Longest common subsequence; `null` marks a part that differs between descriptions.
|
|
const lengths = template.map(() => new Array<number>(tokens.length + 1).fill(0));
|
|
lengths.push(new Array<number>(tokens.length + 1).fill(0));
|
|
for (let row = template.length - 1; row >= 0; row -= 1) {
|
|
for (let column = tokens.length - 1; column >= 0; column -= 1) {
|
|
lengths[row][column] = template[row] !== null && template[row] === tokens[column]
|
|
? lengths[row + 1][column + 1] + 1
|
|
: Math.max(lengths[row + 1][column], lengths[row][column + 1]);
|
|
}
|
|
}
|
|
const result: Array<string | null> = [];
|
|
let row = 0;
|
|
let column = 0;
|
|
const gap = () => { if (result[result.length - 1] !== null) result.push(null); };
|
|
while (row < template.length && column < tokens.length) {
|
|
if (template[row] !== null && template[row] === tokens[column]) {
|
|
result.push(template[row]);
|
|
row += 1;
|
|
column += 1;
|
|
} else {
|
|
gap();
|
|
if (lengths[row + 1][column] >= lengths[row][column + 1]) row += 1; else column += 1;
|
|
}
|
|
}
|
|
if (row < template.length || column < tokens.length) gap();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Wording shared by every description of one alert type (explicit user decision 2026-09-30).
|
|
* Words are compared in source order; every part that differs between alerts becomes `…`.
|
|
* Nothing is added or rephrased. Returns undefined when fewer than two different descriptions
|
|
* exist (the original applies) or when no shared word remains.
|
|
*/
|
|
export function commonDefenderAlertDescription(descriptions: string[]): string | undefined {
|
|
const distinct = [...new Set(descriptions.map(value => value.trim()))];
|
|
if (distinct.length < 2 || distinct.some(value => !value)) return undefined;
|
|
const template = distinct.slice(1).reduce<Array<string | null>>(
|
|
(current, description) => commonTokens(current, description.split(/\s+/)),
|
|
distinct[0].split(/\s+/)
|
|
);
|
|
if (!template.some(token => token !== null)) return undefined;
|
|
return template.map(token => token ?? DIFFERING_PART).join(' ');
|
|
}
|
|
|
|
export function isDefenderAlertHeader(headers: string[]): boolean {
|
|
const present = new Set(headers.map(normalizedHeader));
|
|
return requiredHeaders.every(header => present.has(header));
|
|
}
|
|
|
|
export function defenderAlertSeverity(value: string) {
|
|
return DEFENDER_ALERT_SEVERITIES.find(item => normalizedHeader(item.native) === normalizedHeader(value));
|
|
}
|
|
|
|
function severityRank(value: string): number {
|
|
const index = DEFENDER_ALERT_SEVERITIES.findIndex(item => normalizedHeader(item.native) === normalizedHeader(value));
|
|
return index === -1 ? DEFENDER_ALERT_SEVERITIES.length : index;
|
|
}
|
|
|
|
export function defenderAlertField(row: DefenderAlertSourceRow, header: string): string {
|
|
return text(row, header);
|
|
}
|
|
|
|
function text(row: DefenderAlertSourceRow, header: string): string {
|
|
const key = Object.keys(row.fields).find(candidate => normalizedHeader(candidate) === normalizedHeader(header));
|
|
const value = key === undefined ? undefined : row.fields[key];
|
|
return value === null || value === undefined ? '' : String(value).trim();
|
|
}
|
|
|
|
function acknowledgement(value: string): boolean | undefined {
|
|
if (/^true$/i.test(value)) return true;
|
|
if (/^false$/i.test(value)) return false;
|
|
return undefined;
|
|
}
|
|
|
|
// Rows of one alert repeat every column and differ only in Additional Information.
|
|
const repeatedHeaders = DEFENDER_ALERT_HEADERS.filter(header => header !== 'Additional Information');
|
|
|
|
function alertFromRows(key: string, rows: DefenderAlertSourceRow[]): DefenderAlert {
|
|
const first = rows[0];
|
|
return {
|
|
id: key,
|
|
title: text(first, 'Title'),
|
|
severity: text(first, 'Severity'),
|
|
firstDetection: text(first, 'First Detection'),
|
|
lastDetection: text(first, 'Last Detection'),
|
|
lastActivity: text(first, 'Last Activity'),
|
|
description: text(first, 'Description'),
|
|
acknowledged: text(first, 'Acknowledged'),
|
|
sourceAddress: text(first, 'Source Address'),
|
|
destinationAddress: text(first, 'Destination Address'),
|
|
additionalInformation: rows.map(row => text(row, 'Additional Information')).filter(Boolean),
|
|
rows
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Groups alert export rows into alerts (by native Id) and alert types (by Title).
|
|
* Nothing is dropped: every row stays attached to its alert, disagreements
|
|
* between rows of one alert or one type are reported as diagnostics.
|
|
*/
|
|
export function buildDefenderAlertModel(sourceRows: DefenderAlertSourceRow[]): DefenderAlertModel {
|
|
const diagnostics: DefenderAlertDiagnostic[] = [];
|
|
const rowsByAlert = new Map<string, DefenderAlertSourceRow[]>();
|
|
for (const row of sourceRows) {
|
|
if (!text(row, 'Title')) {
|
|
diagnostics.push({
|
|
severity: 'error', code: 'defender-alert-title-missing', tableIndex: row.tableIndex, rowIndex: row.rowIndex,
|
|
message: 'A Defender for IoT alert row has no Title, so it cannot be assigned to an alert type.'
|
|
});
|
|
continue;
|
|
}
|
|
let key = text(row, 'Id');
|
|
if (!key) {
|
|
key = `row:${row.tableIndex}:${row.rowIndex}`;
|
|
diagnostics.push({
|
|
severity: 'warning', code: 'defender-alert-id-missing', tableIndex: row.tableIndex, rowIndex: row.rowIndex,
|
|
message: 'A Defender for IoT alert row has no Id and is kept as a separate alert.'
|
|
});
|
|
}
|
|
rowsByAlert.set(key, [...(rowsByAlert.get(key) ?? []), row]);
|
|
}
|
|
|
|
const alerts = [...rowsByAlert.entries()].map(([key, rows]) => {
|
|
const differing = repeatedHeaders.filter(header => new Set(rows.map(row => text(row, header))).size > 1);
|
|
if (differing.length) diagnostics.push({
|
|
severity: 'warning', code: 'defender-alert-rows-conflict', tableIndex: rows[0].tableIndex, rowIndex: rows[0].rowIndex,
|
|
message: 'Rows sharing one Defender for IoT alert Id disagree; the first row is shown and every row is retained as evidence.',
|
|
details: { columns: differing, rowCount: rows.length }
|
|
});
|
|
const alert = alertFromRows(key, rows);
|
|
if (acknowledgement(alert.acknowledged) === undefined) diagnostics.push({
|
|
severity: 'warning', code: 'defender-alert-acknowledged-unknown', tableIndex: rows[0].tableIndex, rowIndex: rows[0].rowIndex,
|
|
message: 'A Defender for IoT alert has no readable Acknowledged value (True/False).'
|
|
});
|
|
if (!defenderAlertSeverity(alert.severity)) diagnostics.push({
|
|
severity: 'warning', code: 'defender-alert-severity-unknown', tableIndex: rows[0].tableIndex, rowIndex: rows[0].rowIndex,
|
|
message: 'A Defender for IoT alert has a severity outside Critical, Major, Minor and Warning.'
|
|
});
|
|
return alert;
|
|
});
|
|
|
|
const alertsByTitle = new Map<string, DefenderAlert[]>();
|
|
for (const alert of alerts) alertsByTitle.set(alert.title, [...(alertsByTitle.get(alert.title) ?? []), alert]);
|
|
|
|
const types = [...alertsByTitle.entries()].map(([title, members]): DefenderAlertType => {
|
|
const severities = [...new Set(members.map(alert => alert.severity))];
|
|
if (severities.length > 1) diagnostics.push({
|
|
severity: 'warning', code: 'defender-alert-severity-conflict', tableIndex: members[0].rows[0].tableIndex,
|
|
message: 'Alerts of one Defender for IoT alert type carry different severities; the highest one is used for the type.',
|
|
details: { severities }
|
|
});
|
|
const states = members.map(alert => acknowledgement(alert.acknowledged));
|
|
const unacknowledgedCount = states.filter(state => state === false).length;
|
|
const acknowledgedCount = states.filter(state => state === true).length;
|
|
const unknownAcknowledgementCount = states.length - unacknowledgedCount - acknowledgedCount;
|
|
return {
|
|
title,
|
|
severity: [...severities].sort((left, right) => severityRank(left) - severityRank(right))[0],
|
|
alerts: members,
|
|
unacknowledgedCount,
|
|
acknowledgedCount,
|
|
unknownAcknowledgementCount,
|
|
status: unacknowledgedCount ? 'Unacknowledged' : unknownAcknowledgementCount ? 'Acknowledgement unknown' : 'Acknowledged'
|
|
};
|
|
});
|
|
|
|
return { types, alertCount: alerts.length, rowCount: sourceRows.length, diagnostics };
|
|
}
|