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; /** 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, tokens: string[]): Array { // Longest common subsequence; `null` marks a part that differs between descriptions. const lengths = template.map(() => new Array(tokens.length + 1).fill(0)); lengths.push(new Array(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 = []; 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>( (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(); 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(); 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 }; }