feat(profiles): add Microsoft Defender for IoT Alerts profile
Adapted pick of fdd46e8: recognition, source extractor, domain model and tool profile for Defender for IoT alert exports, the common-description first read with its AGENTS.md rule, and the shared markdown, recognition, status-outcome and scan-sequence changes. Also brings forward two fixes Defender needs on this base: the title fallback in recordTitleFor (from210bbc0) and the Source Rule ID exception for ID columns that are the finding title (from21a6266). Product version 2.12.0 for the Defender series.
This commit is contained in:
parent
2eafefc5a1
commit
45088e9bcd
|
|
@ -136,6 +136,7 @@
|
|||
- 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 decision 2026-09-28 supersedes the Results-only part of the 2026-09-25 Maester rule: the first prose paragraph of Maester's native test result (`ResultDetail`, or `Result` next to a separate `Status` column) is the `Problem` statement, verbatim, cut only where a heading or table begins. Its tables and the complete text stay in Results; source-marked row counts and the https documentation link remain. A bare status word is never a statement. GPO findings use the GPO problem summary rules above.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## UI Pathfinder graph text and details
|
||||
|
|
|
|||
|
|
@ -4231,6 +4231,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
|
|||
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
||||
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
||||
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
||||
| 2026-10-02 | Phasen 6/7/13A, Produktversion 2.12.0: `fdd46e8` (Profil Microsoft Defender for IoT Alerts) angepasst uebernommen. Profil `defender-for-iot-alerts` 1.6.0 mit Domain-Modell, Source Extractor, Manifestzeilen und deklarierten Scoring-/Prioritaets-/Timeslot-Vertraegen (kein nativer Gesamtscore, native Alarm-Severity, kein Timeslot-Vergleich). Alarme werden je Alarmtyp gruppiert; unterschiedliche Beschreibungen eines Typs erscheinen als gemeinsame Woerter mit `…`, die zugehoerige Nutzerregel steht mit ausdruecklicher Governance-Freigabe in `AGENTS.md`. Werkzeuguebergreifend: Unterstriche innerhalb eines Wortes sind in `markdown.ts` keine Auszeichnung mehr; ein Profil kann per `selectExtractedTables` die Tabellen behalten, aus denen sein Extractor Findings gebildet hat; ein Statusmapping kann sein Outcome deklarieren; beide Repositories setzen eine bereits durch einen ersetzten Lauf belegte Scan-Sequenz hinter alle gespeicherten Laeufe. Die Ausblendung von `Source Observations` liegt auf dieser Basis in `FindingReader.tsx`. Zwei Korrekturen aus spaeteren Commits waren fuer Defender auf dieser Basis noetig und sind vorgezogen: aus `210bbc0` der Titel-Rueckfall in `recordTitleFor` (eine leere gewaehlte Titelspalte faellt wieder auf den Datensatztitel zurueck; mit `7d3ef5f` war dieser Rueckfall fuer alle Scanner verloren gegangen) samt Regression fuer die GPO-Zeile, und aus `21a6266` die Ausnahme in `withSourceRuleId`, die eine native ID-Spalte nicht in `Source Rule ID` umbenennt, wenn sie zugleich der Finding-Titel ist (Defender `CVE`, Alert `Title`); die Regel-ID wird dann zusaetzlich angefuegt. Nicht uebernommen: die Versionsangabe 2.10.8 des Originals. Produktversion: Minor-Erhoehung von 2.11.2 auf 2.12.0 fuer die Defender-Serie (zwei neue Scannerprofile, geraetebezogene Risikoakzeptanz); `package.json`, Root-Eintraege in `package-lock.json` und die Versionsvertragstests sind synchron. Die `securityToolId`-Zeile aus `c056c21` folgt mit dem Result-DB-Werkzeugeintrag aus `21a6266`. Neue Profile, daher kein Reimport bestehender Importe; keine SQL-Migration. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.920 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||
| 2026-10-02 | Phasen 7/13A: `d9ff6a8` (geraetebezogene Risikoakzeptanz fuer Defender) angepasst uebernommen. Neben der bestehenden Akzeptanz des ganzen Findings gibt es die Ziele `device-finding` (ein Device mal eine CVE) und `device-snapshot` (alle aktuellen CVEs eines Device) mit unveraenderlichem Zielabbild und append-only Historie. Migration `028_scoped_risk_acceptances.sql` legt die projektgebundene Tabelle `assessor_scoped_risk_acceptances` an (Cascade bei Projektloeschung); beide Repositories, `GET`/`POST /api/v1/projects/:id/risk-acceptances/scoped`, App-State, Defender-Panel (Abdeckungszeile, deckende Entscheidungen, `Accept all current findings`), Finding Reader/Dialog und das Accepted-Risks-Register sind erweitert. Nutzerentscheidung 2026-10-02, abweichend vom Original: (1) Die Zaehlung `current` im Register bleibt bei der Basisregel `Accepted`, `Scheduled`, `Review due`, `Review required`. (2) Geraetebezogene Entscheidungen folgen der Regel fuer ganze Findings: sie decken nur die erfassten Occurrences; nach einem Reimport oder entfernten Scan-Lauf sowie bei einer nicht erfassten Occurrence desselben Device und derselben CVE bleibt die Entscheidung mit vollstaendiger Historie erhalten, zeigt `Review required` und deckt nichts. Der Marker `capturedScopeReplaced` wird bei jedem Lesen aus den aktuellen Scan-Laeufen abgeleitet und nie gespeichert. Offen: Ersetzte geraetebezogene Entscheidungen fliessen nicht in die Reimport-Transferzaehlung je Datei und damit nicht in den Data-Hub-Verarbeitungsstatus ein; das Defender-Panel listet nur deckende Entscheidungen. Neue Regressionen: Domain (erfasste gegen ersetzte Occurrence, Marker, Widerruf, keine Persistenz des Markers), lokales Repository (Reimport desselben Berichts mit geaenderter Device-Identitaet) und Register. Der opt-in PostgreSQL-Integrationstest lief nicht; der PostgreSQL-Lesepfad des Markers ist damit nur typgeprueft. Vor der Migration wurde das verifizierte Backup `backups/assessor-pre-028-20261002-132810.dump` erstellt. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.908 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||
| 2026-10-02 | Phasen 6/7: Defender-Teile von `b438f25` uebernommen (nach `c7c716c`, weil der Commit zunaechst nur als NVD-Korrektur eingeordnet war). CVE-, Device- und Site-Tabelle des Defender-Panels klappen ihre Details direkt unter der angeklickten Zeile auf und wieder zu (Dreieck-Schalter, `aria-expanded`/`aria-controls`); die CVE-Tabelle fuehrt die Beschreibung nur noch im Detail. Der Extractor gibt die Tabelle `Affected Devices` als `Name`, `IP Address`, `Scanner`, `Site` aus. Abweichend vom Original nutzt die aufgeklappte Zeile nach Nutzerentscheidung die blaue Tabellenzeilen-Markierung (`--table-row-marking`, `--table-row-marking-surface`) statt Gelb, und der Drilldown bleibt auf den gemeinsamen Kachelrollen. Nicht uebernommen: die `baseSeverity`-Korrektur im NVD-Client samt Test (CVE-Anreicherung ausgelassen) und der `.gitignore`-Eintrag. Kein Profilversionssprung wie im Original; die Tabellenspalten liegen bereits unter dem danach uebernommenen Profil 1.2.0, die lokale Datenbank enthaelt keine Defender-Importe. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.896 bestandenen und 17 uebersprungenen Tests (ein lastbedingter Einzelausfall in `node-import-engine.test.ts` im ersten Lauf, allein und im Wiederholungslauf bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||
| 2026-10-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1058,6 +1058,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: {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ export function recordTitleFor(record: FindingRecord, selectedColumn = '', fallb
|
|||
const profileTitle = titlePatterns
|
||||
.map(pattern => record.fields.find(field => field.value && matches(pattern, field.label))?.value)
|
||||
.find(Boolean);
|
||||
// An empty selected column (e.g. a scanner `Name` on a GPO row of the mixed
|
||||
// project sheet) must fall through to the record's own title.
|
||||
return profileTitle
|
||||
?? (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value)
|
||||
?? record.title;
|
||||
|| (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value)
|
||||
|| record.title;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,8 +224,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));
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ function withSourceRuleId(fields: OriginalField[], occurrence: FindingOccurrence
|
|||
const sourceRuleId = occurrence.sourceIdentity.originalRuleId?.trim()
|
||||
|| occurrence.sourceIdentity.canonicalRuleId?.trim();
|
||||
if (!sourceRuleId) return fields;
|
||||
// A native ID column that is also the finding title (Defender `CVE`) keeps
|
||||
// its label so profile title headers still resolve; the ID is added instead.
|
||||
const idIsTitle = occurrence.sourceIdentity.originalTitle?.trim() === sourceRuleId;
|
||||
const sourceKeys = new Set(occurrence.fieldMappings
|
||||
.filter(mapping => mapping.targetRole === 'rule-id')
|
||||
.flatMap(mapping => mapping.sourceFieldKeys));
|
||||
|
|
@ -115,7 +118,7 @@ function withSourceRuleId(fields: OriginalField[], occurrence: FindingOccurrence
|
|||
return field;
|
||||
}
|
||||
const ruleIdLabel = /^(?:id|rule\s*id|control\s*id|test\s*id|short\s*name|recommendation)$/i.test(field.label.trim());
|
||||
if (!found && (sourceKeys.has(field.key) || ruleIdLabel) && fieldText(field).trim() === sourceRuleId) {
|
||||
if (!found && !idIsTitle && (sourceKeys.has(field.key) || ruleIdLabel) && fieldText(field).trim() === sourceRuleId) {
|
||||
found = true;
|
||||
return { ...field, label: 'Source Rule ID' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { maesterTableContextSourceExtractor } from './maester-table-context-source-extractor';
|
||||
|
|
@ -66,6 +67,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;
|
||||
|
|
|
|||
|
|
@ -611,6 +611,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,
|
||||
|
|
|
|||
|
|
@ -328,11 +328,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[] {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ describe('mixed Work record titles', () => {
|
|||
expect(recordTitleFor(defender, 'Name', 'generic')).toBe('CVE-2026-4242');
|
||||
});
|
||||
|
||||
it('keeps the GPO title when the aggregate title column is empty on the GPO row', () => {
|
||||
const gpo = record([
|
||||
['Source: Scan', 'GPOs · 2026-09-10T08:00:00.000Z'],
|
||||
['Name', ''],
|
||||
['Title', 'Synthetic policy setting']
|
||||
], 'Synthetic policy setting');
|
||||
|
||||
expect(recordTitleFor(gpo, 'Name', 'generic')).toBe('Synthetic policy setting');
|
||||
});
|
||||
|
||||
it('keeps the selected title column for records without a tool-specific title field', () => {
|
||||
const generic = record([['Name', 'Synthetic finding'], ['Value', '42']]);
|
||||
expect(recordTitleFor(generic, 'Name', 'generic')).toBe('Synthetic finding');
|
||||
|
|
|
|||
|
|
@ -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/FindingReader';
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -294,6 +294,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);
|
||||
|
|
|
|||
|
|
@ -107,6 +107,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