feat(profiles): add Microsoft Defender for IoT Alerts profile
Add recognition, source extractor, domain model and tool profile for Defender for IoT alert exports, including the common-description first read, processing-compatibility entry and repository support. Bump product version to 2.10.8 and update plan and profile docs.
This commit is contained in:
parent
d5c5ae2f20
commit
fdd46e8f93
|
|
@ -74,6 +74,7 @@
|
|||
- Explicit user decision 2026-09-25: every Finding Reader states within seconds what is wrong and what to do. Directly below the setting or rule heading, a `Problem` block (`Summary` for a passed result) with the outcome-colored marker and a separate `Next step` block with the orange `--next-step-accent` marker precede all explanatory text; both markers use `--marker-width`. Background (description, technical explanation, rationale) follows openly, references and documentation after it; work tracking and Additional Information stay collapsed at the end.
|
||||
- Scanner profiles declare their first-read fields in `presentation.firstRead`; React components never name scanner-specific fields. Use only retained source values: a missing field removes its line and nothing is summarized, rephrased or invented. Counts come from explicit source counts or linked result tables; a result row is red or green only where the source marks it (CIS-CAT criterion/evaluated rows with the finding outcome, `resultSheetRowStatusCells` markers). Split a long remediation only at a paragraph boundary and keep the rest complete behind `Full remediation`. An imported documentation link is offered only as a plain https URL with `noopener noreferrer`.
|
||||
- Explicit user decision 2026-09-25: every documented standard Windows code shown as text carries its meaning directly after it, in muted regular text: well-known SIDs, standardized domain RIDs (`S-1-5-21-<domain>-500/512/...`; the domain part is never resolved), User Rights Assignment constants (`SeNetworkLogonRight` -> `Access this computer from the network`) and registry type codes (`4` -> `REG_DWORD`). The single list lives in `src/shared/windows-codes.ts` and is applied centrally by `PathText`; the original code stays unchanged and authoritative, unknown or customer-specific codes stay plain, and a meaning already stated next to the code is not repeated.
|
||||
- Explicit user exception 2026-09-30 (Microsoft Defender for IoT Alerts): when the alerts of one alert type carry different native descriptions, the finding shows the whitespace-separated words shared by every description in source order, each differing part replaced by `…`. Nothing is added or rephrased. Explicit user correction of the same date: it is labeled plain `Description` without a derived-text suffix; the separate field key `common-description` keeps it distinguishable from a native description. A single alert or identical texts keep the original `Description` unchanged, including addresses (user decision); if no word is shared, no derived text appears. Every original description stays unchanged in the Alerts Results table. This exception applies to no other scanner or field.
|
||||
- Maester's native test result (ResultDetail) stays in Results only (2026-09-25 decision); its first read shows the source-marked row counts and the https documentation link. GPO findings use the GPO problem summary rules above.
|
||||
- Explicit user correction 2026-09-25: when a scanner's first-read remediation explicitly introduces a PowerShell command, render that command in its own readable, contained block beneath the explanatory sentence. Keep the original command text complete and copyable, do not rewrite its parameters or split the remaining remediation before the original paragraph boundary, and retain the full source field unchanged.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# ASSESSOR – Umsetzungsplan
|
||||
|
||||
Defender-for-IoT-Alertimport 2026-09-30 (Phase 6/13A, Nutzerauftrag mit Nutzerentscheidungen, umgesetzt und verifiziert, Version 2.10.0): Neues Profil `defender-for-iot-alerts` 1.0.0 mit Source Extractor 1.0.0 fuer den OT-Sensor-Alertexport, neben dem unveraenderten CVE-Profil. Entscheidungen: nativer CSV-Export und XLSX mit einer unveraenderten CSV-Zeile je Zelle (Originalzeile als Provenienz); ein Finding je Alert-Typ (`Title`), Alerts je nativer `Id` mit allen Zeilen als Evidenz; Severity Critical->Critical, Major->High, Minor->Medium, Warning->Low mit erhaltenem Nativwert; `Unacknowledged` ist Deviation, vollstaendig `Acknowledged` bleibt sichtbar ohne Deviation. Dafuer erhalten deklarierte Status-Mappings ein optionales `outcome` (generische Erweiterung, bestehende Profile unveraendert). Kein nativer Score, findings-only, daher kein Pass-Rate-Beitrag; kein Timeslot-Vergleich. result_type bleibt `unknown` (geschlossenes Enum); die Result DB zeigt das Werkzeug bis zu einer eigenen Disclosure-Policy-Erweiterung als generische Quelle. Neues Profil ohne Altimporte, daher kein Reimport und keine Migration. Verifikation: 6 synthetische Alert-Tests (CSV und umbrochene XLSX, Gruppierung, Severity/Status, Work-/Result-Deviation, Diagnosen), Gesamtsuite 1.825 bestanden und 17 uebersprungen (einziger Fehler war der waehrend des Versionssprungs gelaufene Versionstest, danach 9/9 gruen), Local-only-/Designcheck und beide Produktionsbuilds bestanden. Inhaltsfreie Pruefung der zwei anonymisierten Realexporte: vollstaendige Extraktion ohne Diagnosen, alle 723 bzw. 1.068 Zeilen als Evidenz, je 500 Alerts in 16 bzw. 20 Alert-Typen. Grenzen: Zeitstempel ohne Zeitzone bleiben Quelltext; Alert-Gruppen mit genau 25 Zeilen deuten auf eine Exportbegrenzung von Additional Information hin, die nicht diagnostiziert wird. Minorversion fuer die neue rueckwaertskompatible Importfaehigkeit.
|
||||
|
||||
Referenzerkennung 2026-09-26 (Phase 13D, erster Umfang umgesetzt und verifiziert, Version 2.8.0): ATT&CK/D3FEND werden gemeinsam aus gespeicherten Originalfeldern erkannt und mit Quellen-/Katalogherkunft in eigenen Laeufen gespeichert. Normale Batchimports leiten automatisch ab; bestehende Scans werden ueber `Recognize references` im Clientbereich explizit nachverarbeitet. Pathfinder und Reader nutzen die gespeicherten Referenzen. PostgreSQL-Migration 023 mit vorherigem verifiziertem Backup; keine Client-Datenbanktrennung, Finding-Identitaets-, Scoring- oder Review-Aenderung. Siehe Phase 13D und `docs/REFERENCE_RECOGNITION.md`.
|
||||
|
||||
Status: lebendes Projektdokument
|
||||
|
|
@ -2914,6 +2916,24 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
|
|||
|
||||
## 12. Änderungsprotokoll
|
||||
|
||||
2026-09-30 - Phase 6/7 / Defender-Alert ohne Acknowledged Alerts (2.10.8, umgesetzt und verifiziert): Nutzerentscheidung: Die Exporte enthalten nur fehlgeschlagene (unbestaetigte) Alerts, daher entfaellt das Feld `Acknowledged Alerts`. Der native `Acknowledged`-Wert bleibt je Zeile in der Alerts-Tabelle und steuert weiterhin den Status (ein vollstaendig bestaetigter Typ waere keine Deviation). Extractor 1.5.0, Profil 1.6.0 (Presentation 1.5.0, `reimportRequired`). Gesamtsuite 1.831 bestanden, 17 uebersprungen, Local-only-/Designcheck und beide Produktionsbuilds bestanden. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 6/7 / Defender-Alert Beschreibungslabel (2.10.7, umgesetzt und verifiziert): Nutzerkorrektur: Der gemeinsame Beschreibungstext heisst schlicht `Description` ohne Zusatz; der eigene Feldschluessel `common-description` unterscheidet ihn von einer nativen Beschreibung. Einzelalerts behalten ihre Originalbeschreibung einschliesslich Adressen (Nutzerentscheidung; eine Adressmaskierung wurde angeboten und abgelehnt). AGENTS.md-Ausnahme entsprechend ergaenzt. Extractor 1.4.0, Profil 1.5.0 (Mapping 1.2.0, Presentation 1.4.0, `reimportRequired`). Gesamtsuite 1.830 bestanden, 17 uebersprungen; ein fachfremder UI-Kostentest ueberschritt unter Last das 5-s-Limit und bestand einzeln. Local-only-/Designcheck und beide Produktionsbuilds bestanden. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 6/7 / Defender-Alert gemeinsame Beschreibung (2.10.6, umgesetzt und verifiziert): Ausdrueckliche Nutzerausnahme zur Finding-Reader-Regel, in AGENTS.md festgehalten: Haben die Alerts eines Alert-Typs unterschiedliche Beschreibungen, zeigt das Finding `Description (common to all alerts)` mit den allen Beschreibungen gemeinsamen, leerzeichengetrennten Woertern in Quellreihenfolge (LCS), jede abweichende Stelle als `…`. Nichts wird ergaenzt oder umformuliert; Einzelalert und identische Texte behalten die Originalbeschreibung, ohne gemeinsames Wort entfaellt der Text; alle Originalbeschreibungen bleiben in der Alerts-Tabelle. Der Reader stellt das Feld wie `Description` voran. Extractor 1.3.0, Profil 1.4.0 (Mapping 1.1.0, Presentation 1.3.0, append-only Manifest, `reimportRequired`). Gesamtsuite 1.831 bestanden, 17 uebersprungen, Local-only-/Designcheck und beide Produktionsbuilds bestanden; inhaltsfreie Realpruefung: 16 bzw. 20 Alert-Typen, 2 bzw. 6 mit Originalbeschreibung, je 14 mit abgeleitetem Text (78-98 % behaltene Woerter), keiner ohne Beschreibung. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 7 / Unterstriche in Woertern (2.10.5, umgesetzt und verifiziert): Der gemeinsame Inline-Markdown-Parser las jedes `_..._`-Paar als Kursivschrift, auch innerhalb von Woertern (`[IP_189], Destination IP: [IP_188]` verlor Unterstriche und erschien kursiv). Wie in CommonMark oeffnen/schliessen `_` und `__` nur noch ohne Buchstabe, Ziffer oder Unterstrich davor bzw. dahinter; `*`-Auszeichnung, Code und Verweise unveraendert. Reine Darstellungskorrektur fuer alle Werkzeuge ueber bereits gespeicherte Werte, kein Reimport. Synthetische Parser- und Alerts-Tabellen-Renderregression; Gesamtsuite 1.830 bestanden, 17 uebersprungen, Local-only-/Designcheck und beide Produktionsbuilds bestanden. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 6/13A / Defender-Alert Detail Rows (2.10.4, umgesetzt und verifiziert): Nutzerentscheidung: Alert Count zaehlt weiterhin eindeutige Alert-Ids; jedes Alert-Typ-Finding traegt zusaetzlich `Detail Rows` mit der Zahl der Quellzeilen, also der Zeilen der Alerts-Tabelle (ein Alert kann mehrere Zeilen je Additional-Information-Wert haben). Extractor 1.2.0, Profil 1.3.0 (Presentation 1.2.0, append-only Manifest, `reimportRequired`). Gesamtsuite 1.828 bestanden, 17 uebersprungen, Local-only-/Designcheck und beide Produktionsbuilds bestanden; inhaltsfreie Realpruefung: 500/500 Alerts, 723/1.068 Detail Rows, je Finding identisch mit der Tabellenzeilenzahl. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 11/12 / Importabschluss nach ersetztem Scan (2.10.3, umgesetzt und verifiziert): Ein Batchimport in einen Timeslot scheiterte mit PostgreSQL-Eindeutigkeitskonflikt, weil der Server die naechste Scan-Reihenfolge aus dem Assessmentmodell ohne ersetzte (superseded) Laeufe ableitete und ein ersetzter Lauf die hoechste Reihenfolge hielt (inhaltsfreie Zaehlung im kanonischen Datenbestand: 1 betroffener Timeslot). PostgreSQL- und lokales Repository verschieben eine bereits belegte Reihenfolge beim Abschluss nun hinter alle gespeicherten Laeufe des Timeslots; die Transaktion sperrt die Assessmentzeile vorher. Ersetzte Laeufe, Historie und Daten bleiben unveraendert; keine Migration, kein Reimport. Synthetische Regression im Repositorytest; Gesamtsuite 1.828 bestanden, 17 uebersprungen, Local-only-/Designcheck und beide Produktionsbuilds bestanden. Die opt-in PostgreSQL-Integrationstests wurden ohne separate Testdatenbank nicht ausgefuehrt. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 6/13A / Defender-Alerttabelle je Quellzeile (2.10.2, umgesetzt und verifiziert): Nutzerentscheidung: Die Alerts-Tabelle im Ergebnisfenster zeigt jede Quellzeile einzeln in Quellreihenfolge statt einer zusammengefassten Zeile je Alert-Id; Zeilen derselben Id unterscheiden sich nur in Additional Information und behalten jeweils ihren eigenen Wert. Findings, Zaehlungen, Severity und Status bleiben unveraendert. Weil die Tabelle beim Import gespeichert wird: Extractor 1.1.0, Profil 1.2.0 (Presentation 1.1.0, append-only Manifest, `reimportRequired`, Dimensionen `evidence`/`presentation`). Gesamtsuite 1.827 bestanden, 17 uebersprungen, Local-only-/Designcheck und beide Produktionsbuilds bestanden; inhaltsfreie Pruefung der Realexporte ueber den Serverpfad: 723 bzw. 1.068 Tabellenzeilen, Status `recognized`. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 6/13A / Defender-Alertimport: umbrochene XLSX nicht sichtbar (2.10.1, umgesetzt und verifiziert): Ein realer Import der zeilenweise umbrochenen Arbeitsmappe mit Profil 1.0.0 erschien in Data Hub als `Not viewable`, weil die einzige, nicht erkennbare Textspalte keine ausgewaehlte Tabelle war und die kanonische Projektion alle Findings verwarf. Profile koennen nun per `tableRules.selectExtractedTables` Tabellen auswaehlen, aus denen ihr validierter, nicht fehlgeschlagener Source Extractor Findings erzeugt hat; nur das Alert-Profil setzt dies. Eine zunaechst generische Regel aenderte die Tabellenauswahl von Purple Knight und wurde deshalb verworfen. Profil 1.1.0 (append-only Manifest, `reimportRequired`, Dimension `recognition`) markiert mit 1.0.0 importierte Alertscans zum Reimport. Synthetische Regression prueft umbrochenes XLSX bis zu gespeicherten Vorkommen und Data-Hub-Status `recognized`; Gesamtsuite 1.827 bestanden, 17 uebersprungen, Local-only-/Designcheck bestanden. Patchversion.
|
||||
|
||||
2026-09-30 - Phase 6/13A / Defender-for-IoT-Alertimport (2.10.0, umgesetzt und verifiziert): Neues Profil `defender-for-iot-alerts` 1.0.0 und Extractor 1.0.0 fuer CSV und zeilenweise umbrochenes XLSX; ein Finding je Alert-Typ, Severity 1:1, Unacknowledged als Deviation, optionales Outcome in deklarierten Status-Mappings. Kein Reimport, keine Migration; Details im Statusnachtrag oben.
|
||||
|
||||
2026-09-26 - Phase 6/7 / Scanner-Einstieg, CIS-Abschnitte, Work-Zusammenfuehrung (2.8.6, umgesetzt und verifiziert): Purple-Knight-Umfangsnamen loesen ueber Profil-`runNames` auf; Maester-Abschnitt `Remediation action` als Next step; PowerShell-Skripte nach `script ...:` bzw. mit Variablenzuweisung als Befehlsblock mit Anzeige-Zeilenumbruechen; Purple-Knight-Likelihood ohne angehaengte Referenzen; CIS-Abschnittsnamen aus dem eigenen Scanlauf in `CIS Category`, Work-Ueberschriften und Plaketten-Tooltips; `withRebuiltLayout` erhaelt beim GPO-Anhang alle Datensatzmetadaten. Patchversion, kein Reimport; Details im Statusnachtrag oben.
|
||||
|
||||
2026-09-26 - Phase 13D / gemischte Work-Projektion (2.8.5, umgesetzt und verifiziert): Beim Anhaengen von GPO-Ergebnissen an All Records wird referenceRecognition jetzt vom jeweiligen Scannerbefund uebernommen. Zuvor blieben die Referenzlaeufe gespeichert, gingen aber beim Neuaufbau der Reader-Projektion verloren. Die Uebernahme bewahrt Referenzen, Quellenherkunft, Erkennerversion, Lauf und Katalogversion/-hash einschliesslich bewusst leerer Erkennung; GPOs und nicht indexierte Befunde erhalten keine fremden Referenzen. Synthetische Regression oeffnet Referenzdetails im echten FindingReader mit und ohne GPOs. 30 fokussierte Referenz-/GPO-/CIS-Tests und 9 UI-/API-Versionsvertragstests, beide Produktionsbuilds sowie Design-/Local-only- und Diffpruefung bestanden. Patchversion fuer rueckwaertskompatible Projektionskorrektur; keine Datenmutation und kein Reimport bzw. neuer Erkennungslauf erforderlich. Bereits geladene Workspaces muessen neu geladen werden. Phase 13D bleibt in Arbeit.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ Das Schema steht in `src/domain/tool-profile-definition.ts`. Die gemeinsame Lauf
|
|||
| CIS-CAT Pro | 1.0.0 | Assessment Results, Recommendation und Pass/Fail |
|
||||
| Maester | 1.0.0 | flache TestResults-Exporte, MT-IDs und ResultDetail |
|
||||
| Microsoft Defender for IoT | 1.2.0 | Asset-Summary, Mock-CVE-CSV, Device-Inventar, Device-ID-Verknuepfung und Sensorprovenienz |
|
||||
| Microsoft Defender for IoT Alerts | 1.6.0 | OT-Sensor-Alertexport (CSV oder XLSX mit einer CSV-Zeile je Zelle), ein Finding je Alert-Typ |
|
||||
|
||||
Die Definitionen liegen jeweils im Verzeichnis des Werkzeugs. `profile-definition-catalog.ts` stellt sie der Erkennungsengine bereit; `profile-catalog.ts` stellt die daraus erzeugten Laufzeitprofile für den Import und die Oberfläche bereit.
|
||||
|
||||
|
|
@ -67,6 +68,8 @@ Formatparser, Erkennungsalgorithmus und React-Komponenten müssen dafür nicht g
|
|||
|
||||
Microsoft Defender for IoT verwendet weiterhin den generischen Excel-/CSV-Formatadapter. Ein kleiner Source Extractor verarbeitet sowohl das bisherige Summary-/Detailtabellenpaar als auch die synthetischen `Mock-Defender`-CSV-Dateien `cves.csv` und `device_inventory.csv`. Die Mock-CVE-Datei wiederholt Site und Sensor als ausdruecklich simulierte Fixture-Felder, damit ein Einzelimport die CVE-Ansicht vollstaendig speist; bei einem Mehrdateiimport verbindet das Domain-Modell CVE- und Inventarzeilen weiterhin ausschliesslich ueber die explizite `Device ID`. Fehlende Kontext- oder Beschreibungswerte werden nicht erfunden. Der Extractor erzeugt genau ein Finding-Vorkommen je Device und CVE, aggregiert diese je CVE und bewahrt CVSS Score, Component, Status sowie wiederholte Sensorzeilen als Evidenz. Summary-Werte ohne Detail-CVE bleiben separate Aggregate und werden nicht in Findings umgewandelt. Die fachlichen CVE-, Device- und Site-Projektionen liegen ausserhalb der React-Komponente in einem deterministischen Domain-Modell.
|
||||
|
||||
Microsoft Defender for IoT Alerts (Profil `defender-for-iot-alerts`, Nutzerentscheidungen 2026-09-30) liest den OT-Sensor-Alertexport mit den Spalten `Id, Title, First Detection, Last Detection, Last Activity, Severity, Description, Acknowledged, Additional Information, Source Address, Destination Address`. Akzeptiert werden der native CSV-Export und eine Arbeitsmappe, deren Zellen in Spalte A je eine unveraenderte CSV-Zeile enthalten; die Originalzeile bleibt dann als `rawLine` Provenienz. Zeilen mit derselben `Id` bilden einen Alert und unterscheiden sich nur in `Additional Information`; alle Zeilen bleiben Evidenz, abweichende Wiederholungsspalten werden diagnostiziert. Ein Finding entspricht einem Alert-Typ (`Title` als Rule ID) mit einer Alerts-Tabelle im Ergebnisfenster, die jede Quellzeile einzeln zeigt (ab Profil 1.2.0). `Alert Count` zaehlt eindeutige Alert-Ids, `Detail Rows` (ab Profil 1.3.0) die Tabellenzeilen. Ab Profil 1.4.0 zeigt ein Alert-Typ mit unterschiedlichen Beschreibungen die allen Beschreibungen gemeinsamen Woerter, abweichende Stellen als `…`; ab Profil 1.5.0 unter dem schlichten Label `Description` (Feldschluessel `common-description`; Nutzerausnahme, siehe AGENTS.md). Severity wird eins zu eins abgebildet (Critical->Critical, Major->High, Minor->Medium, Warning->Low); `Defender Severity` behaelt den Nativwert. `Unacknowledged` (mindestens ein Alert mit `Acknowledged=False`) ist Deviation, vollstaendig bestaetigte Typen bleiben als `Acknowledged` sichtbar und sind keine Deviation. Der Export ist findings-only ohne nativen Gesamtscore und liefert deshalb keinen Pass-Rate-Beitrag; Zeitstempel bleiben unveraendert als Quelltext (Format ohne Zeitzone). Unlesbare Zeilen und fehlende Titel brechen die Extraktion ausdruecklich ab.
|
||||
|
||||
## Darstellung von Textfeldern
|
||||
|
||||
Verbindlich für alle Werkzeuge und alle Ansichten: **kein Aufrufer formatiert einen Feldwert selbst.** Jeder werkzeuggelieferte Fließtext wird über `RichTextValue` ausgegeben, das die Auswertung an `src/shared/markdown.ts` delegiert. Ein neues Profil braucht dafür keine eigene Regel.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.9.0",
|
||||
"version": "2.10.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1071,6 +1071,13 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
|||
RETURNING id
|
||||
`, [model.assessment.id, model.assessment.projectId, model.assessment.name, model.assessment.createdAt, model.assessment.updatedAt, json(model.assessment)]);
|
||||
if (!assessmentResult.rowCount) throw new RepositoryConflictError('Ein Assessment kann nicht in ein anderes Projekt verschoben werden.');
|
||||
// Superseded runs keep their sequence but are hidden from the model the caller derived the next
|
||||
// sequence from; a taken sequence therefore moves behind every stored run of the timeslot.
|
||||
const takenSequence = await client.query<{ next: number } & QueryResultRow>(`
|
||||
SELECT coalesce(max(sequence), 0) + 1 AS next FROM assessor_scan_runs
|
||||
WHERE assessment_id = $1 HAVING bool_or(sequence = $2)
|
||||
`, [run.assessmentId, run.sequence]);
|
||||
if (takenSequence.rows[0]) run.sequence = Number(takenSequence.rows[0].next);
|
||||
await client.query(
|
||||
'INSERT INTO assessor_scan_runs (id, assessment_id, sequence, imported_at, data) VALUES ($1, $2, $3, $4, $5::jsonb)',
|
||||
[run.id, run.assessmentId, run.sequence, run.importedAt, json(run)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -27,6 +27,13 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
|
|||
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: false },
|
||||
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'], current: false },
|
||||
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.2.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['recognition', 'extraction', 'finding-identity', 'evidence'], current: true },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: false },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['recognition'], current: false },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.2.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.1.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: false },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.3.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.2.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: false },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.4.0', mappingVersion: '1.1.0', normalizationVersion: '1.0.0', presentationVersion: '1.3.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['mapping', 'evidence', 'presentation'], current: false },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.5.0', mappingVersion: '1.2.0', normalizationVersion: '1.0.0', presentationVersion: '1.4.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['mapping', 'presentation'], current: false },
|
||||
{ profileId: 'defender-for-iot-alerts', profileVersion: '1.6.0', mappingVersion: '1.2.0', normalizationVersion: '1.0.0', presentationVersion: '1.5.0', compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: true },
|
||||
{ profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'purple-knight-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
|
||||
{ profileId: 'purple-knight', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
|
||||
{ profileId: 'purple-knight', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false },
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export interface ProfileFieldRule {
|
|||
|
||||
export interface ProfileValueMapping<T extends string> {
|
||||
normalizedValue: T;
|
||||
/** Status mappings only: outcome when it deliberately differs from the legacy-status default. */
|
||||
outcome?: CanonicalOutcome;
|
||||
patterns: RegExp[];
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +158,11 @@ export interface ToolProfileDefinition {
|
|||
tableRules: {
|
||||
preferredNames: WeightedPattern[];
|
||||
discouragedNames: WeightedPattern[];
|
||||
/**
|
||||
* Select every table the validated source extractor of this profile derived findings from,
|
||||
* even when its raw columns carry no recognizable headers.
|
||||
*/
|
||||
selectExtractedTables?: boolean;
|
||||
};
|
||||
fieldRules: ProfileFieldRule[];
|
||||
normalization: {
|
||||
|
|
|
|||
|
|
@ -162,8 +162,8 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
|
|||
// Die Defender-Rohbeobachtungen bleiben als maschinenlesbare Provenienz im
|
||||
// Datensatz (die Defender-Sonderansicht rekonstruiert daraus ihr Modell),
|
||||
// gehoeren aber nicht in die Lesespalte: der fachliche Beleg steht als
|
||||
// "Affected Devices"-Tabelle im Ergebnisfenster.
|
||||
if (toolId === 'defender-for-iot-vulnerabilities') {
|
||||
// "Affected Devices"- bzw. "Alerts"-Tabelle im Ergebnisfenster.
|
||||
if (toolId === 'defender-for-iot-vulnerabilities' || toolId === 'defender-for-iot-alerts') {
|
||||
record.fields
|
||||
.filter(field => normalizeLabel(field.label) === 'source observations')
|
||||
.forEach(field => hiddenFieldIndexes.add(field.index));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import type { JsonValue } from '../domain/canonical-model';
|
||||
import {
|
||||
buildDefenderAlertModel,
|
||||
commonDefenderAlertDescription,
|
||||
DEFENDER_FOR_IOT_ALERTS_PROFILE_ID,
|
||||
defenderAlertField,
|
||||
defenderAlertSeverity,
|
||||
isDefenderAlertHeader,
|
||||
type DefenderAlert,
|
||||
type DefenderAlertDiagnostic,
|
||||
type DefenderAlertSourceRow,
|
||||
type DefenderAlertType
|
||||
} from '../domain/defender-for-iot-alerts';
|
||||
import type { DecodedSourceDocument, ImportTable, SourceLocator } from '../domain/import-intermediate';
|
||||
import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction';
|
||||
import { parseCsvLexically } from './csv-importer';
|
||||
|
||||
interface AlertTableHeader {
|
||||
rowIndex: number;
|
||||
headers: string[];
|
||||
/** Each CSV line sits in one cell (a CSV opened in Excel and saved without splitting). */
|
||||
wrapped: boolean;
|
||||
}
|
||||
|
||||
function filledCells(row: ImportTable['rows'][number]) {
|
||||
return row.cells.filter(cell => cell.decodedValue.trim());
|
||||
}
|
||||
|
||||
function wrappedHeaders(value: string): string[] | undefined {
|
||||
const parsed = parseCsvLexically(value, ',');
|
||||
const headers = parsed.rows.length === 1 && !parsed.unclosedQuote ? parsed.rows[0].map(header => header.trim()) : undefined;
|
||||
return headers && isDefenderAlertHeader(headers) ? headers : undefined;
|
||||
}
|
||||
|
||||
function alertTableHeader(table: ImportTable): AlertTableHeader | undefined {
|
||||
const candidate = table.headerCandidates[0];
|
||||
if (candidate && isDefenderAlertHeader(candidate.headers)) return { rowIndex: candidate.rowIndex, headers: candidate.headers, wrapped: false };
|
||||
for (const row of table.rows.slice(0, 5)) {
|
||||
const cells = filledCells(row);
|
||||
if (isDefenderAlertHeader(row.cells.map(cell => cell.decodedValue))) {
|
||||
return { rowIndex: row.index, headers: row.cells.map(cell => cell.decodedValue.trim()), wrapped: false };
|
||||
}
|
||||
const headers = cells.length === 1 && row.cells[0]?.decodedValue.trim() ? wrappedHeaders(row.cells[0].decodedValue) : undefined;
|
||||
if (headers) return { rowIndex: row.index, headers, wrapped: true };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tableRows(table: ImportTable, header: AlertTableHeader, diagnostics: DefenderAlertDiagnostic[]): DefenderAlertSourceRow[] {
|
||||
const rows: DefenderAlertSourceRow[] = [];
|
||||
for (const row of table.rows) {
|
||||
if (row.index <= header.rowIndex || !filledCells(row).length) continue;
|
||||
if (!header.wrapped) {
|
||||
rows.push({
|
||||
tableIndex: table.index,
|
||||
rowIndex: row.index,
|
||||
fields: Object.fromEntries(header.headers.map((name, index) => [name || `Column ${index + 1}`, row.cells[index]?.rawValue ?? null]))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const line = row.cells[0]?.decodedValue ?? '';
|
||||
const parsed = parseCsvLexically(line, ',');
|
||||
if (row.cells.slice(1).some(cell => cell.decodedValue.trim()) || parsed.unclosedQuote
|
||||
|| parsed.rows.length !== 1 || parsed.rows[0].length !== header.headers.length) {
|
||||
diagnostics.push({
|
||||
severity: 'error', code: 'defender-alert-wrapped-row-unreadable', tableIndex: table.index, rowIndex: row.index,
|
||||
message: 'A Defender for IoT alert line in the workbook does not match the alert export columns.',
|
||||
details: { fieldCount: parsed.rows[0]?.length ?? 0, expectedFieldCount: header.headers.length }
|
||||
});
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
tableIndex: table.index,
|
||||
rowIndex: row.index,
|
||||
fields: Object.fromEntries(header.headers.map((name, index) => [name, parsed.rows[0][index]])),
|
||||
rawLine: line
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function field(key: string, label: string, originalValue: JsonValue, decodedValue: string, source: SourceLocator): SourceFieldCandidate {
|
||||
return { key, label, originalValue, decodedValue, source };
|
||||
}
|
||||
|
||||
function cell(value: string): string {
|
||||
// Pipe und Zeilenumbruch wuerden die Markdown-Tabellenstruktur brechen.
|
||||
return value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ').trim();
|
||||
}
|
||||
|
||||
const alertTableColumns = ['Id', 'Acknowledged', 'First Detection', 'Last Detection', 'Last Activity',
|
||||
'Source Address', 'Destination Address', 'Description', 'Additional Information'];
|
||||
|
||||
// Die Alerts eines Alert-Typs als Markdown-Tabelle fuer das Ergebnisfenster,
|
||||
// eine Zeile je Quellzeile in Quellreihenfolge (Nutzerentscheidung 2026-09-30):
|
||||
// Zeilen derselben Alert-Id bleiben einzeln sichtbar, jede mit ihrer eigenen
|
||||
// Additional Information.
|
||||
function alertsTable(alerts: DefenderAlert[]): string {
|
||||
const header = `| ${alertTableColumns.join(' | ')} |\n|${alertTableColumns.map(() => ' --- |').join('')}`;
|
||||
const rows = alerts.flatMap(alert => alert.rows.map(row => `| ${alertTableColumns
|
||||
.map(column => cell(defenderAlertField(row, column))).join(' | ')} |`));
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
function observationValue(row: DefenderAlertSourceRow): Record<string, JsonValue> {
|
||||
return {
|
||||
fields: row.fields,
|
||||
...(row.rawLine === undefined ? {} : { rawLine: row.rawLine }),
|
||||
source: { tableIndex: row.tableIndex, rowIndex: row.rowIndex }
|
||||
};
|
||||
}
|
||||
|
||||
function typeCandidate(document: DecodedSourceDocument, type: DefenderAlertType, index: number): FindingCandidate {
|
||||
const firstRow = type.alerts[0].rows[0];
|
||||
const source = { fileName: document.file.name, tableIndex: firstRow.tableIndex, rowIndex: firstRow.rowIndex };
|
||||
const severity = defenderAlertSeverity(type.severity);
|
||||
const descriptions = [...new Set(type.alerts.map(alert => alert.description))];
|
||||
const commonDescription = commonDefenderAlertDescription(descriptions);
|
||||
const observations = type.alerts.flatMap(alert => alert.rows.map(observationValue));
|
||||
const table = alertsTable(type.alerts);
|
||||
return {
|
||||
version: 1,
|
||||
id: `defender-alert-type-${index}`,
|
||||
source,
|
||||
fields: [
|
||||
field('title', 'Title', type.title, type.title, source),
|
||||
field('severity', 'Severity', severity?.label ?? type.severity, severity?.label ?? type.severity, source),
|
||||
field('defender-severity', 'Defender Severity', type.severity, type.severity, source),
|
||||
field('status', 'Status', type.status, type.status, source),
|
||||
// One shared native description describes the type; differing ones stay per alert.
|
||||
...(descriptions.length === 1 && descriptions[0] ? [field('description', 'Description', descriptions[0], descriptions[0], source)] : []),
|
||||
// Shown as Description (user decision 2026-09-30); the key keeps it apart from a native description,
|
||||
// and the original per-alert descriptions stay in the Alerts table.
|
||||
...(commonDescription ? [field('common-description', 'Description', commonDescription, commonDescription, source)] : []),
|
||||
field('alert-count', 'Alert Count', type.alerts.length, String(type.alerts.length), source),
|
||||
// Rows of the Alerts table: one alert may span several source rows (one per Additional Information value).
|
||||
field('detail-rows', 'Detail Rows', observations.length, String(observations.length), source),
|
||||
field('unacknowledged-alerts', 'Unacknowledged Alerts', type.unacknowledgedCount, String(type.unacknowledgedCount), source),
|
||||
field('alerts', 'Alerts', table, table, source),
|
||||
field('source-observations', 'Source Observations', observations, JSON.stringify(observations), source)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export const defenderForIotAlertSourceExtractor: SourceExtractor = {
|
||||
id: 'defender-for-iot-alert-source-extractor',
|
||||
version: '1.5.0',
|
||||
detect(document) {
|
||||
const headers = document.tables.map(alertTableHeader).filter(Boolean) as AlertTableHeader[];
|
||||
if (!headers.length) return { score: 0, evidence: [] };
|
||||
return {
|
||||
score: 1,
|
||||
toolId: DEFENDER_FOR_IOT_ALERTS_PROFILE_ID,
|
||||
evidence: [headers.some(header => header.wrapped)
|
||||
? 'A Defender for IoT alert export with one CSV line per workbook cell was found.'
|
||||
: 'A Defender for IoT alert export table was found.']
|
||||
};
|
||||
},
|
||||
extract(document) {
|
||||
const diagnostics: DefenderAlertDiagnostic[] = [];
|
||||
const rows = document.tables.flatMap(table => {
|
||||
const header = alertTableHeader(table);
|
||||
return header ? tableRows(table, header, diagnostics) : [];
|
||||
});
|
||||
const model = buildDefenderAlertModel(rows);
|
||||
return {
|
||||
version: 1,
|
||||
candidates: model.types.map((type, index) => typeCandidate(document, type, index)),
|
||||
diagnostics: [...diagnostics, ...model.diagnostics].map(diagnostic => ({
|
||||
severity: diagnostic.severity,
|
||||
code: diagnostic.code,
|
||||
message: diagnostic.message,
|
||||
stage: 'extraction',
|
||||
source: diagnostic.tableIndex === undefined ? undefined : {
|
||||
fileName: document.file.name,
|
||||
tableIndex: diagnostic.tableIndex,
|
||||
rowIndex: diagnostic.rowIndex
|
||||
},
|
||||
details: diagnostic.details
|
||||
}))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '..
|
|||
import { stringifyCell } from '../shared/text';
|
||||
import { cisCatSourceExtractor } from './cis-cat-source-extractor';
|
||||
import { defenderForIotSourceExtractor } from './defender-for-iot-source-extractor';
|
||||
import { defenderForIotAlertSourceExtractor } from './defender-for-iot-alert-source-extractor';
|
||||
import { pingCastleSourceExtractor } from './pingcastle-source-extractor';
|
||||
import { maesterHtmlSourceExtractor } from './maester-html-source-extractor';
|
||||
import { purpleKnightHtmlSourceExtractor } from './purple-knight-html-source-extractor';
|
||||
|
|
@ -65,6 +66,7 @@ export const nativeObjectSourceExtractor: SourceExtractor = {
|
|||
|
||||
export const sourceExtractors: SourceExtractor[] = [
|
||||
defenderForIotSourceExtractor,
|
||||
defenderForIotAlertSourceExtractor,
|
||||
pingCastleSourceExtractor,
|
||||
cisCatSourceExtractor,
|
||||
purpleKnightHtmlSourceExtractor,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function normalizeStatusDimensionsFor(toolId: string | undefined, value:
|
|||
|
||||
const declared = definition?.normalization.status;
|
||||
const declaredMatch = declared?.find(mapping => mapping.patterns.some(pattern => pattern.test(cleaned)));
|
||||
if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue);
|
||||
if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue, declaredMatch.outcome);
|
||||
|
||||
const fallback = fallbackRules.find(rule => rule.pattern.test(cleaned.toLocaleLowerCase('de-DE')));
|
||||
return fallback ? dimensionsFor(fallback.legacy, fallback.outcome) : UNKNOWN_DIMENSIONS;
|
||||
|
|
|
|||
|
|
@ -637,6 +637,12 @@ export class LocalAssessorRepository implements AssessorRepository {
|
|||
}
|
||||
if (assessmentIndex >= 0) next.assessments[assessmentIndex] = clone(model.assessment);
|
||||
else next.assessments.push(clone(model.assessment));
|
||||
// Superseded runs keep their sequence but are hidden from the model the caller derived the next
|
||||
// sequence from; a taken sequence therefore moves behind every stored run of the timeslot.
|
||||
const timeslotSequences = next.scanRuns.filter(run => run.assessmentId === model.assessment.id).map(run => run.sequence);
|
||||
model.scanRuns.forEach(run => {
|
||||
if (timeslotSequences.includes(run.sequence)) run.sequence = Math.max(...timeslotSequences) + 1;
|
||||
});
|
||||
appendUnique(next.scanRuns, model.scanRuns, 'Scan-Lauf');
|
||||
appendUnique(next.importFiles, importFiles, 'Importdatei');
|
||||
appendUnique(next.sourceTables, model.sourceTables, 'Quelltabelle');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { DEFENDER_ALERT_SEVERITIES, DEFENDER_FOR_IOT_ALERTS_PROFILE_ID } from '../../domain/defender-for-iot-alerts';
|
||||
import type { ToolProfileDefinition } from '../../domain/tool-profile-definition';
|
||||
|
||||
export const defenderForIotAlertsDefinition: ToolProfileDefinition = {
|
||||
schemaVersion: 1,
|
||||
id: DEFENDER_FOR_IOT_ALERTS_PROFILE_ID,
|
||||
securityScoring: {
|
||||
version: '1.0.0',
|
||||
native: { kind: 'unavailable', reason: 'The alert export lists detected alerts only; it carries no native overall score and no passed checks.' },
|
||||
targets: [], resultPopulation: 'findings-only'
|
||||
},
|
||||
priorityBasis: {
|
||||
version: '1.0.0',
|
||||
// Every alert states its native sensor severity; that is the only ordering the source declares.
|
||||
basis: { kind: 'severity', properties: ['alert.severity'] },
|
||||
excludedProperties: [
|
||||
{ property: 'alert.count', reason: 'How often an alert type fired, not its criticality.' }
|
||||
]
|
||||
},
|
||||
timeslotComparison: {
|
||||
version: '1.0.0', kind: 'unavailable',
|
||||
reason: 'The Defender for IoT alert export lists the alerts present at export time without a declared monitoring scope; an absent alert does not prove remediation.'
|
||||
},
|
||||
name: 'Microsoft Defender for IoT Alerts',
|
||||
version: '1.6.0',
|
||||
processing: {
|
||||
mappingVersion: '1.2.0', normalizationVersion: '1.0.0', presentationVersion: '1.5.0',
|
||||
compatibilityKey: 'defender-for-iot-alerts-v1', changeClass: 'compatible',
|
||||
// The Acknowledged Alerts count is no longer retained; the export lists failed (unacknowledged) alerts.
|
||||
reimportRequired: true, affectedDimensions: ['evidence', 'presentation']
|
||||
},
|
||||
sourceSignals: [
|
||||
{ scope: 'header', pattern: /^Acknowledged$/i, contribution: 0.3, message: 'The alert export exposes the Acknowledged state.' },
|
||||
{ scope: 'header', pattern: /^Last Activity$/i, contribution: 0.2, message: 'The alert export exposes Last Activity.' },
|
||||
{ scope: 'header', pattern: /^Additional Information$/i, contribution: 0.15, message: 'The alert export exposes Additional Information.' },
|
||||
{ scope: 'header', pattern: /^(?:Source|Destination) Address$/i, contribution: 0.15, message: 'The alert export exposes source and destination addresses.' },
|
||||
// A single-column workbook yields no usable header candidate, so the wrapped header is found among the cells.
|
||||
{ scope: 'cell-value', pattern: /^Id,Title,First Detection,Last Detection,Last Activity,Severity,Description,Acknowledged,/i, contribution: 0.6, message: 'The workbook holds the alert export header as one CSV line.' }
|
||||
],
|
||||
tableRules: {
|
||||
preferredNames: [{ pattern: /alert/i, contribution: 0.45, message: 'Likely alert table.' }],
|
||||
discouragedNames: [],
|
||||
// A workbook with one CSV line per cell is a single unrecognizable text column.
|
||||
selectExtractedTables: true
|
||||
},
|
||||
fieldRules: [
|
||||
{ role: 'rule-id', headers: [{ pattern: /^Title$/i, contribution: 1, message: 'The native alert title identifies the alert type.' }] },
|
||||
{ role: 'severity', headers: [{ pattern: /^Severity$/i, contribution: 1, message: 'The field contains the alert severity.' }] },
|
||||
{ role: 'status', headers: [{ pattern: /^Status$/i, contribution: 1, message: 'The field contains the acknowledgement state of the alert type.' }] },
|
||||
{ role: 'description', headers: [{ pattern: /^Description$/i, contribution: 1, message: 'The field contains the native alert description or the wording shared by all alert descriptions of this type.' }] },
|
||||
{ role: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every source row and row locator.' }] }
|
||||
],
|
||||
normalization: {
|
||||
severity: DEFENDER_ALERT_SEVERITIES.map(item => ({
|
||||
normalizedValue: item.normalized,
|
||||
// Native and normalized labels are plain words without regex syntax.
|
||||
patterns: [new RegExp(`^(?:${item.native}|${item.label})$`, 'i')]
|
||||
})),
|
||||
status: [
|
||||
// An open alert nobody acknowledged is a deviation; an acknowledged one stays visible without being one.
|
||||
{ normalizedValue: 'open', outcome: 'warning', patterns: [/^unacknowledged$/i] },
|
||||
{ normalizedValue: 'open', outcome: 'informational', patterns: [/^acknowledged$/i] },
|
||||
{ normalizedValue: 'unknown', patterns: [/^acknowledgement unknown$/i] }
|
||||
]
|
||||
},
|
||||
presentation: {
|
||||
titleHeaders: [/^Title$/i],
|
||||
severityHeaders: [/^Severity$/i],
|
||||
sortHeaders: [/^Severity$/i],
|
||||
sortDirection: 'desc',
|
||||
defaultStatusFilter: 'deviation',
|
||||
statusHeaders: [/^Status$/i],
|
||||
statusFilters: [
|
||||
{ value: 'all', label: 'All Alerts', patterns: [] },
|
||||
{ value: 'deviation', label: 'Unacknowledged', patterns: [/^unacknowledged$/i] },
|
||||
{ value: 'acknowledged', label: 'Acknowledged', patterns: [/^acknowledged$/i] }
|
||||
],
|
||||
resultSectionHeaders: [/^Alerts$/i]
|
||||
},
|
||||
comparisonKeys: [['rule-id']]
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { createImportProfile } from '../profile-factory';
|
||||
import { defenderForIotAlertsDefinition } from './defender-for-iot-alerts-definition';
|
||||
|
||||
export const defenderForIotAlertsProfile = createImportProfile(defenderForIotAlertsDefinition);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ImportProfile } from '../domain/import-profile';
|
||||
import { cisCatProfile } from './cis-cat/cis-cat-profile';
|
||||
import { defenderForIotProfile } from './defender-for-iot/defender-for-iot-profile';
|
||||
import { defenderForIotAlertsProfile } from './defender-for-iot-alerts/defender-for-iot-alerts-profile';
|
||||
import { genericProfile } from './generic/generic-profile';
|
||||
import { maesterProfile } from './maester/maester-profile';
|
||||
import { pingCastleProfile } from './pingcastle/pingcastle-profile';
|
||||
|
|
@ -8,6 +9,7 @@ import { purpleKnightProfile } from './purple-knight/purple-knight-profile';
|
|||
|
||||
export const importProfiles: ImportProfile[] = [
|
||||
defenderForIotProfile,
|
||||
defenderForIotAlertsProfile,
|
||||
purpleKnightProfile,
|
||||
pingCastleProfile,
|
||||
cisCatProfile,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ToolProfileDefinition } from '../domain/tool-profile-definition';
|
||||
import { cisCatDefinition } from './cis-cat/cis-cat-definition';
|
||||
import { defenderForIotDefinition } from './defender-for-iot/defender-for-iot-definition';
|
||||
import { defenderForIotAlertsDefinition } from './defender-for-iot-alerts/defender-for-iot-alerts-definition';
|
||||
import { genericDefinition } from './generic/generic-definition';
|
||||
import { maesterDefinition } from './maester/maester-definition';
|
||||
import { pingCastleDefinition } from './pingcastle/pingcastle-definition';
|
||||
|
|
@ -8,6 +9,7 @@ import { purpleKnightDefinition } from './purple-knight/purple-knight-definition
|
|||
|
||||
export const toolProfileDefinitions: ToolProfileDefinition[] = [
|
||||
defenderForIotDefinition,
|
||||
defenderForIotAlertsDefinition,
|
||||
purpleKnightDefinition,
|
||||
pingCastleDefinition,
|
||||
cisCatDefinition,
|
||||
|
|
|
|||
|
|
@ -108,8 +108,11 @@ function recognizeSource(document: ImportDocument, extraction?: SourceExtraction
|
|||
};
|
||||
}
|
||||
|
||||
function recognizeTable(table: ImportTable, fields: FieldRoleRecognition[], definition: ToolProfileDefinition): TableRecognition {
|
||||
function recognizeTable(table: ImportTable, fields: FieldRoleRecognition[], definition: ToolProfileDefinition, extractedFrom: boolean): TableRecognition {
|
||||
const evidence: RecognitionEvidence[] = [];
|
||||
// Opt-in per profile: findings the validated source extractor built from this table must not be dropped
|
||||
// only because its raw columns carry no recognizable headers (e.g. one CSV line per workbook cell).
|
||||
if (extractedFrom && definition.tableRules.selectExtractedTables) evidence.push({ signal: 'source-extractor', contribution: 1, message: 'The validated source extractor derived findings from this table.' });
|
||||
const name = clean(table.name);
|
||||
definition.tableRules.preferredNames.forEach(rule => {
|
||||
rule.pattern.lastIndex = 0;
|
||||
|
|
@ -154,7 +157,11 @@ export function recognizeImportDocument(document: ImportDocument, extraction?: S
|
|||
const fields = document.tables.flatMap(table =>
|
||||
table.columns.map(column => recognizeField(table, column.index, definition.fieldRules))
|
||||
);
|
||||
const tables = document.tables.map(table => recognizeTable(table, fields.filter(field => field.tableId === table.id), definition));
|
||||
const extractedTableIndexes = new Set(extraction?.match.toolId === source.selected.toolId && extraction.processing.status !== 'failed'
|
||||
? extraction.candidates.map(candidate => candidate.source.tableIndex).filter((index): index is number => index !== undefined)
|
||||
: []);
|
||||
const tables = document.tables.map(table => recognizeTable(table, fields.filter(field => field.tableId === table.id), definition,
|
||||
extractedTableIndexes.has(table.index)));
|
||||
return {
|
||||
version: 1,
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -327,11 +327,17 @@ function splitCells(line: string): string[] {
|
|||
* (`**[Titel](Adresse)**`). Ohne Rekursion stuende die innere Auszeichnung
|
||||
* woertlich im fetten Text. Der Abbruch ist gesichert: der rekursive Aufruf
|
||||
* erhaelt stets eine echt kuerzere Zeichenkette als die erkannte Fundstelle.
|
||||
*
|
||||
* Unterstriche innerhalb eines Wortes (`IP_189`, `snake_case`) sind wie in
|
||||
* CommonMark keine Auszeichnung: `_`/`__` oeffnen nur ohne Buchstabe, Ziffer
|
||||
* oder Unterstrich davor und schliessen nur ohne einen davon dahinter.
|
||||
*/
|
||||
const INLINE = new RegExp(
|
||||
'(`[^`\\n]+`)'
|
||||
+ `|(${LINK_PATTERN})`
|
||||
+ '|(\\*\\*[^*\\n]+\\*\\*)|(__[^_\\n]+__)|(\\*[^*\\n]+\\*)|(_[^_\\n]+_)'
|
||||
+ '|(\\*\\*[^*\\n]+\\*\\*)|((?<![\\p{L}\\p{N}_])__[^_\\n]+__(?![\\p{L}\\p{N}_]))'
|
||||
+ '|(\\*[^*\\n]+\\*)|((?<![\\p{L}\\p{N}_])_[^_\\n]+_(?![\\p{L}\\p{N}_]))',
|
||||
'u'
|
||||
);
|
||||
|
||||
export function parseInline(text: string): InlineToken[] {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
|||
|
||||
describe('ASSESSOR version in the brand header', () => {
|
||||
it('shows the centrally maintained product version and opens About from the brand', () => {
|
||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.0');
|
||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.10.8');
|
||||
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
|
||||
expect(app).toContain('aria-label="About ASSESSOR"');
|
||||
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { importFile } from '../../src/importers/importer';
|
||||
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
|
||||
import { importProfiles } from '../../src/profiles/profile-catalog';
|
||||
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
||||
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
|
||||
import { createScannerUnifiedResult } from '../../src/domain/unified-result';
|
||||
import { isDeviationRecord } from '../../src/importers/status-normalization';
|
||||
import { recordTitleFor } from '../../src/features/dataset-viewer/record-title';
|
||||
import { datahubScanReadiness } from '../../src/features/project-import/datahub-scan-readiness';
|
||||
import { commonDefenderAlertDescription } from '../../src/domain/defender-for-iot-alerts';
|
||||
import { selectFindingFields } from '../../src/features/finding-reader/finding-fields';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { RichTextValue } from '../../src/components/RichTextValue/RichTextValue';
|
||||
|
||||
// Synthetic alert export: fictional titles, TEST-NET addresses and documentation MACs only.
|
||||
const header = 'Id,Title,First Detection,Last Detection,Last Activity,Severity,Description,Acknowledged,Additional Information,Source Address,Destination Address';
|
||||
const lines = [
|
||||
'A-1,Synthetic Unauthorized PLC Programming,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic programming from 192.0.2.41,False,192.0.2.21 over port CIP (44818),192.0.2.41,192.0.2.21',
|
||||
'A-1,Synthetic Unauthorized PLC Programming,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic programming from 192.0.2.41,False,192.0.2.22 over port CIP (44818),192.0.2.41,192.0.2.21',
|
||||
'A-2,Synthetic Unauthorized PLC Programming,12/09/2026 08:00:00,12/09/2026 08:01:00,12/09/2026 08:01:00,Critical,"Synthetic programming, second source",False,,192.0.2.42,192.0.2.21',
|
||||
'A-3,Synthetic Internet Connection,13/09/2026 09:00:00,13/09/2026 09:30:00,13/09/2026 09:30:00,Major,Synthetic outbound connection,False,198.51.100.7 over port HTTPS (443),00-00-5E-00-53-01,',
|
||||
'A-4,Synthetic Unused Protocol,14/09/2026 07:00:00,14/09/2026 07:00:00,14/09/2026 07:05:00,Warning,Synthetic unused protocol,True,,192.0.2.50,192.0.2.51'
|
||||
];
|
||||
|
||||
function csvFile() {
|
||||
return new File([new TextEncoder().encode([header, ...lines].join('\n'))], 'alerts-synthetic.csv', { type: 'text/csv' });
|
||||
}
|
||||
|
||||
function wrappedWorkbook(rows = [header, ...lines]) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows.map(line => [line])), 'Sheet1');
|
||||
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
||||
return new File([bytes], 'alerts-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
|
||||
function fieldValue(candidate: { fields: Array<{ label: string; originalValue: unknown; decodedValue: string }> } | undefined, label: string) {
|
||||
return candidate?.fields.find(field => field.label === label);
|
||||
}
|
||||
|
||||
async function canonicalFor(file: File) {
|
||||
const imported = await importFile(file, { profiles: importProfiles });
|
||||
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
|
||||
sha256: 'a'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
||||
}), importProfiles);
|
||||
return { imported, canonical: createCanonicalImportBatch('defender-alerts-synthetic', [entry], '2026-09-30T06:00:00.000Z').model };
|
||||
}
|
||||
|
||||
describe('Microsoft Defender for IoT alert export', () => {
|
||||
it.each([
|
||||
['native CSV', csvFile],
|
||||
['workbook with one CSV line per cell', () => wrappedWorkbook()]
|
||||
])('imports the %s as one finding per alert type', async (_name, file) => {
|
||||
const imported = await importFile(file(), { profiles: importProfiles });
|
||||
|
||||
expect(imported.profileId).toBe('defender-for-iot-alerts');
|
||||
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-alert-source-extractor', version: '1.5.0' });
|
||||
expect(imported.extraction?.diagnostics).toEqual([]);
|
||||
const candidates = imported.extraction?.candidates ?? [];
|
||||
expect(candidates.map(candidate => fieldValue(candidate, 'Title')?.decodedValue)).toEqual([
|
||||
'Synthetic Unauthorized PLC Programming', 'Synthetic Internet Connection', 'Synthetic Unused Protocol'
|
||||
]);
|
||||
const programming = candidates[0];
|
||||
// Two rows share Id A-1 and differ only in Additional Information: one alert, both lines kept.
|
||||
expect(fieldValue(programming, 'Alert Count')?.originalValue).toBe(2);
|
||||
// Alert A-1 spans two source rows, so the Alerts table holds three rows for two alerts.
|
||||
expect(fieldValue(programming, 'Detail Rows')?.originalValue).toBe(3);
|
||||
expect(fieldValue(programming, 'Unacknowledged Alerts')?.originalValue).toBe(2);
|
||||
expect(fieldValue(programming, 'Acknowledged Alerts')).toBeUndefined();
|
||||
expect(fieldValue(programming, 'Status')?.decodedValue).toBe('Unacknowledged');
|
||||
// Differing descriptions: Description holds the wording shared by all alerts under its own field key.
|
||||
expect(fieldValue(programming, 'Description')).toMatchObject({ key: 'common-description', decodedValue: 'Synthetic …' });
|
||||
const table = fieldValue(programming, 'Alerts')?.decodedValue ?? '';
|
||||
// One table row per source row: alert A-1 appears twice, each with its own Additional Information.
|
||||
const tableRows = table.split('\n').slice(2);
|
||||
expect(tableRows).toHaveLength(3);
|
||||
expect(tableRows.filter(row => row.startsWith('| A-1 | False | 11/09/2026 10:05:12 |'))).toHaveLength(2);
|
||||
expect(tableRows[0]).toMatch(/\| 192\.0\.2\.21 over port CIP \(44818\) \|$/);
|
||||
expect(tableRows[1]).toMatch(/\| 192\.0\.2\.22 over port CIP \(44818\) \|$/);
|
||||
expect(tableRows[2]).toContain('Synthetic programming, second source');
|
||||
expect(fieldValue(programming, 'Source Observations')?.originalValue).toHaveLength(3);
|
||||
|
||||
// Native severity stays retained; Major maps to High and Warning to Low.
|
||||
expect(fieldValue(candidates[1], 'Severity')?.decodedValue).toBe('High');
|
||||
expect(fieldValue(candidates[1], 'Defender Severity')?.decodedValue).toBe('Major');
|
||||
// A single alert keeps its original description.
|
||||
expect(fieldValue(candidates[1], 'Description')).toMatchObject({ key: 'description', decodedValue: 'Synthetic outbound connection' });
|
||||
expect(fieldValue(candidates[2], 'Severity')?.decodedValue).toBe('Low');
|
||||
expect(fieldValue(candidates[2], 'Status')?.decodedValue).toBe('Acknowledged');
|
||||
});
|
||||
|
||||
it('stores the wrapped workbook findings so Data Hub lists the scan as viewable', async () => {
|
||||
// Regression 2026-09-30: the single unrecognized text column was not a selected table, so every finding was dropped.
|
||||
const { canonical } = await canonicalFor(wrappedWorkbook());
|
||||
expect(canonical.findingOccurrences).toHaveLength(3);
|
||||
expect(datahubScanReadiness([canonical], canonical.scanRuns[0].id, 'defender-for-iot-alerts')).toBe('recognized');
|
||||
});
|
||||
|
||||
it('keeps only the wording shared by every description and marks differing parts', () => {
|
||||
expect(commonDefenderAlertDescription([
|
||||
'A Broadcast connection not allowed by policy has been detected.Source IP: 192.0.2.189, Destination IP: 192.0.2.188, Destination port: 17',
|
||||
'A Broadcast connection not allowed by policy has been detected.Source IP: 192.0.2.192, Destination IP: 192.0.2.193, Destination port: Netbios Name Service (137)',
|
||||
'A Multicast connection not allowed by policy has been detected.Source IP: 192.0.2.204, Destination IP: 192.0.2.194, Destination port: Web Service Discovery (3702)'
|
||||
])).toBe('A … connection not allowed by policy has been detected.Source IP: … Destination IP: … Destination port: …');
|
||||
// One distinct text keeps the original; nothing shared yields no derived text.
|
||||
expect(commonDefenderAlertDescription(['Same text', 'Same text'])).toBeUndefined();
|
||||
expect(commonDefenderAlertDescription(['alpha beta', 'gamma delta'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders underscores inside alert descriptions literally in the Results table', async () => {
|
||||
// Regression 2026-09-30: `[IP_189], Destination IP: [IP_` was rendered as italics and lost its underscores.
|
||||
const description = 'Synthetic broadcast.Source IP: [IP_189], Destination IP: [IP_188], Destination port: [PORT_17]';
|
||||
const imported = await importFile(new File([new TextEncoder().encode([header,
|
||||
`A-7,Synthetic Broadcast,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Major,"${description}",False,,192.0.2.1,192.0.2.255`
|
||||
].join('\n'))], 'alerts-underscore.csv', { type: 'text/csv' }), { profiles: importProfiles });
|
||||
const table = fieldValue(imported.extraction?.candidates[0], 'Alerts')!.decodedValue;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToStaticMarkup(createElement(RichTextValue, { value: table }));
|
||||
expect(container.querySelector('em')).toBeNull();
|
||||
expect([...container.querySelectorAll('td')].some(cell => cell.textContent === description)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the original single-cell line as provenance for a wrapped workbook', async () => {
|
||||
const imported = await importFile(wrappedWorkbook(), { profiles: importProfiles });
|
||||
expect(imported.recognition?.source).toMatchObject({ selected: { toolId: 'defender-for-iot-alerts' }, requiresConfirmation: false });
|
||||
const observations = fieldValue(imported.extraction?.candidates[1], 'Source Observations')?.originalValue as Array<Record<string, unknown>>;
|
||||
expect(observations[0]).toMatchObject({ rawLine: lines[3], fields: { Id: 'A-3', Severity: 'Major', 'Destination Address': '' } });
|
||||
});
|
||||
|
||||
it('counts unacknowledged alert types as deviations and acknowledged ones as visible non-deviations', async () => {
|
||||
const { canonical } = await canonicalFor(csvFile());
|
||||
const results = canonical.findingOccurrences.map(occurrence => createScannerUnifiedResult({
|
||||
project: canonical.project, assessment: canonical.assessment, scanRun: canonical.scanRuns[0], importFile: canonical.importFiles[0],
|
||||
definition: canonical.findingDefinitions.find(definition => definition.id === occurrence.definitionId)!, occurrence, reviews: canonical.reviews
|
||||
}));
|
||||
const byRule = new Map(canonical.findingOccurrences.map((occurrence, index) => [occurrence.sourceIdentity.originalRuleId, { occurrence, result: results[index] }]));
|
||||
|
||||
expect(canonical.findingDefinitions).toHaveLength(3);
|
||||
expect(byRule.get('Synthetic Unauthorized PLC Programming')).toMatchObject({
|
||||
occurrence: { severity: { normalizedValue: 'critical' }, outcome: { normalizedValue: 'warning' } },
|
||||
result: { isDeviation: true }
|
||||
});
|
||||
expect(byRule.get('Synthetic Internet Connection')?.occurrence.severity.normalizedValue).toBe('high');
|
||||
expect(byRule.get('Synthetic Unused Protocol')).toMatchObject({
|
||||
occurrence: { severity: { normalizedValue: 'low' }, outcome: { normalizedValue: 'informational' } },
|
||||
result: { isDeviation: false }
|
||||
});
|
||||
expect(canonical.importFiles[0]).toMatchObject({ processedWith: { profileId: 'defender-for-iot-alerts', compatibilityKey: 'defender-for-iot-alerts-v1' } });
|
||||
|
||||
// Work reads the same deviation from the projected record and names it by the alert title.
|
||||
const projected = canonicalModelToDataset(canonical)!;
|
||||
const sheet = projected.sheets.find(item => item.toolId === 'defender-for-iot-alerts' && item.analysis.records.length)!;
|
||||
const deviations = sheet.analysis.records.filter(record => isDeviationRecord(record, sheet.toolId));
|
||||
expect(deviations.map(record => recordTitleFor(record, '', sheet.toolId)).sort()).toEqual([
|
||||
'Synthetic Internet Connection', 'Synthetic Unauthorized PLC Programming'
|
||||
]);
|
||||
// The Finding Reader leads with the shared wording as Description.
|
||||
const programming = sheet.analysis.records.find(record => recordTitleFor(record, '', sheet.toolId) === 'Synthetic Unauthorized PLC Programming')!;
|
||||
expect(selectFindingFields(programming, 'Synthetic Unauthorized PLC Programming', true, sheet.toolId).summary[0])
|
||||
.toMatchObject({ label: 'Description', value: 'Synthetic …' });
|
||||
});
|
||||
|
||||
it('reports conflicting rows, unreadable wrapped lines and missing titles instead of dropping them silently', async () => {
|
||||
const imported = await importFile(wrappedWorkbook([
|
||||
header,
|
||||
lines[0],
|
||||
lines[1].replace('Critical', 'Major'),
|
||||
'A-9,Synthetic broken line,"unterminated',
|
||||
'A-10,,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic,False,,192.0.2.1,192.0.2.2'
|
||||
]), { profiles: importProfiles });
|
||||
|
||||
// A failed extraction is never a silent success: the export is recognized but needs confirmation.
|
||||
expect(imported.recognition?.source).toMatchObject({ selected: { toolId: 'defender-for-iot-alerts' }, requiresConfirmation: true });
|
||||
expect(imported.extraction?.diagnostics.map(item => item.code).sort()).toEqual([
|
||||
'defender-alert-rows-conflict', 'defender-alert-title-missing', 'defender-alert-wrapped-row-unreadable'
|
||||
]);
|
||||
expect(imported.extraction?.processing.status).toBe('failed');
|
||||
// Both conflicting rows remain evidence of the single alert.
|
||||
expect(fieldValue(imported.extraction?.candidates[0], 'Source Observations')?.originalValue).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not claim a vulnerability export or a generic table', async () => {
|
||||
const imported = await importFile(new File([new TextEncoder().encode('Id,Title,Severity\n1,Synthetic,High')], 'other.csv', { type: 'text/csv' }), { profiles: importProfiles });
|
||||
expect(imported.profileId).not.toBe('defender-for-iot-alerts');
|
||||
});
|
||||
});
|
||||
|
|
@ -277,6 +277,29 @@ describe('LocalAssessorRepository contract', () => {
|
|||
.toEqual({ replacesScanRunId: original.id, transferred: 1, retainedByStableIdentity: 0, unresolved: 0 });
|
||||
});
|
||||
|
||||
it('moves a canonical import behind a superseded run that still holds the claimed sequence', async () => {
|
||||
// Regression 2026-09-30: the server derives the next sequence from a model without superseded runs,
|
||||
// so a new import claimed a sequence a superseded run still held and PostgreSQL rejected it.
|
||||
const repository = await repositoryWithProject();
|
||||
await commitRun(repository, scanRun(1));
|
||||
const replacement = scanRun(2);
|
||||
await repository.commitCanonicalImport({
|
||||
sessionId: 'canonical-replacement', replacesScanRunId: 'run-1',
|
||||
model: { schemaVersion: 1, project, assessment, scanRuns: [replacement], reviews: [], ...importData(replacement, { occurrenceId: 'occurrence-run-2' }) },
|
||||
fingerprints: [{ sha256: 'a'.repeat(64), fileName: 'run-2.csv', sizeBytes: 100, lastModified: 0 }]
|
||||
});
|
||||
const next = { ...scanRun(3), sequence: 1 };
|
||||
const nextData = importData(next, { occurrenceId: 'occurrence-run-3', sha256: 'c'.repeat(64) });
|
||||
|
||||
await repository.commitCanonicalImport({
|
||||
sessionId: 'canonical-after-superseded',
|
||||
model: { schemaVersion: 1, project, assessment, scanRuns: [next], reviews: [], ...nextData },
|
||||
fingerprints: [{ sha256: 'c'.repeat(64), fileName: 'run-3.csv', sizeBytes: 100, lastModified: 0 }]
|
||||
});
|
||||
const exported = await repository.exportProject(project.id);
|
||||
expect(exported.scanRuns.map(run => [run.id, run.sequence]).sort()).toEqual([['run-1', 1], ['run-2', 2], ['run-3', 3]]);
|
||||
});
|
||||
|
||||
it('rejects adoption of a newer scan whose SHA-256 source differs', async () => {
|
||||
const repository = await repositoryWithProject();
|
||||
const original = scanRun(1);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
|
|||
|
||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||
expect(await health.json()).toEqual({
|
||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.9.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.10.8', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||
});
|
||||
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ describe('Inline-Auszeichnung', () => {
|
|||
expect(parseInline('Wert `Get-Item` pruefen')[1]).toEqual({ kind: 'code', text: 'Get-Item' });
|
||||
});
|
||||
|
||||
it('liest Unterstriche innerhalb eines Wortes woertlich (CommonMark)', () => {
|
||||
const text = 'Source IP: [IP_189], Destination IP: [IP_188] via snake_case_name and my__dunder__name';
|
||||
expect(parseInline(text)).toEqual([{ kind: 'text', text }]);
|
||||
expect(parseInline('a _kursiv_ b')[1]).toEqual({ kind: 'emphasis', children: [{ kind: 'text', text: 'kursiv' }] });
|
||||
expect(parseInline('__fett__.')[0]).toEqual({ kind: 'strong', children: [{ kind: 'text', text: 'fett' }] });
|
||||
});
|
||||
|
||||
it('liest doppelte Sternchen als Fett, nicht als zwei kursive Teile', () => {
|
||||
expect(parseInline('**Next**')).toEqual([{ kind: 'strong', children: [{ kind: 'text', text: 'Next' }] }]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue