Assessor_2.0/src/domain/defender-for-iot-alerts.ts

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 };
}