diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 5a9fec7..0ed7362 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4462,6 +4462,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: `5fbbda2` uebernommen. Der Defender-Extractor erzeugt nun ein Finding je CVE statt je Device-mal-CVE und liefert `Affected Device Count` sowie die Tabelle `Affected Devices` (Device, Scanner, Site); das Profil bildet die Anzahl auf die Asset-Rolle ab, nutzt `Affected Devices` als Ergebnisabschnitt und vergleicht nur noch ueber die CVE. Der Finding Reader blendet das Rohfeld `Source Observations` fuer Defender aus, es bleibt als Provenienz im Datensatz. Nicht uebernommen: die Umbenennung der Asset-Zeile im Result-DB-Zeilendetail, weil die Disclosure-Policy `affectedAsset` fuer jede Zeile zurueckhaelt und eine Device-Anzahl dort nicht erscheinen soll. Die Finding-Identitaet aendert sich wie im Original ohne Profilversionssprung; die neu angelegte lokale Datenbank enthaelt keine Defender-Importe, der Versionssprung folgt mit `c7c716c`. Keine API-, SQL- oder Persistenzmigration. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/7/13A: `7d3ef5f` (Microsoft Defender for IoT, Grundlage) auf Michaels Basis uebernommen. Profil `defender-for-iot-vulnerabilities` 1.0.0 mit Manifestzeile, Source Extractor, Domain-Modell, Client-Info-Panel sowie Titel je Datensatzprofil (`recordTitleFor`) in Work und Pathfinder. Angepasst: Das Profil deklariert die auf dieser Basis verpflichtenden `securityScoring` (kein nativer Gesamtscore, mit Begruendung), `priorityBasis` (native CVE-Severity) und `timeslotComparison` (nicht verfuegbar). Client Info fuehrt Defender als dritten Tab neben Overview und AI Overview; ein reiner Defender-Timeslot startet in der Defender-Sicht. `recordTitleFor` ersetzt nur den Titelkandidaten, die Maester-/CIS-Titelregeln bleiben. `canonicalModelsToDataset` laesst zusaetzlich Modelle mit befuellten Quelltabellen zu, das leere All-Records-Blatt bleibt bestehen. Die Kompatibilitaetspruefung bevorzugt den gespeicherten `processedWith.profileId`; der sichtbare Importvertrag steigt auf 1.4.0. Die kopierten `.client-info-tabs`-Regeln sind aus `ai-overview.css` entfernt und liegen nur noch in `defender-for-iot.css`. Nicht uebernommen: `.gitignore`-Eintrag, `--tile-inset`, der bereits vorhandene `fileProfile`-Vorrang und die alte Versionspruefung. Neues Profil, daher kein Reimport bestehender Importe; keine API-, SQL- oder Persistenzmigration. Die Produktversion wird am Ende der Defender-Serie einmal neu bewertet. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 7/13F: Aus `95ec322` die AI-Overview- und ISO-Mapping-Korrekturen uebernommen. Schlaegt eine Regeneration fehl oder laeuft sie noch, liefert der AI-Overview-Service zusaetzlich `previousSuccessfulRun`; das Panel zeigt die letzte gespeicherte Uebersicht mit der Fehlermeldung weiter, `Stale` gilt nur fuer einen erfolgreichen, veralteten Lauf. Ein gespeichertes ISO-Mapping bleibt lesbar, wenn Qdrant oder der Embeddingdienst nicht erreichbar ist; die Generierung bleibt dann deaktiviert. Synthetische Service- und Paneltests. Die Defender-Geraete-Risikoakzeptanz desselben Commits folgt mit den Defender-Commits. | | 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. | diff --git a/src/features/finding-reader/FindingReader.tsx b/src/features/finding-reader/FindingReader.tsx index abaa096..2050584 100644 --- a/src/features/finding-reader/FindingReader.tsx +++ b/src/features/finding-reader/FindingReader.tsx @@ -214,6 +214,15 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr .filter(field => isPingCastleLinkedSectionField(field.label)) .forEach(field => hiddenFieldIndexes.add(field.index)); } + // 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') { + record.fields + .filter(field => normalizeLabel(field.label) === 'source observations') + .forEach(field => hiddenFieldIndexes.add(field.index)); + } if (hasStructuredResults) { record.fields .filter(field => isRedundantDetailField(field.label)) diff --git a/src/importers/defender-for-iot-source-extractor.ts b/src/importers/defender-for-iot-source-extractor.ts index 460f5e4..fa3c09a 100644 --- a/src/importers/defender-for-iot-source-extractor.ts +++ b/src/importers/defender-for-iot-source-extractor.ts @@ -2,8 +2,10 @@ import type { JsonValue } from '../domain/canonical-model'; import { buildDefenderVulnerabilityModel, defenderTableRole, + type DefenderCve, type DefenderDevice, type DefenderObservation, + type DefenderOccurrence, type DefenderSourceRow, type DefenderSourceTable } from '../domain/defender-for-iot'; @@ -48,32 +50,49 @@ function field(key: string, label: string, originalValue: JsonValue, decodedValu return { key, label, originalValue, decodedValue, source }; } -function occurrenceCandidate( +function deviceLabel(device: DefenderDevice | undefined, fallback: string): string { + return device?.displayName || device?.ip || fallback; +} + +function cell(value: string): string { + // Zellen einer Markdown-Tabelle: Pipe und Zeilenumbruch wuerden die + // Tabellenstruktur brechen, deshalb neutralisiert. + return value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ').trim(); +} + +// Die betroffenen Devices je CVE als Markdown-Tabelle. Sie wird im +// Ergebnisfenster als Tabelle ausgewertet (siehe RichTextValue) und ersetzt so +// die frueher als Rohtext gezeigten Source Observations. Eine Zeile je Device +// (Occurrence) mit den meldenden Sensoren und dem Standort. +function affectedDevicesTable(occurrences: DefenderOccurrence[], deviceById: Map): string { + const header = '| Affected Device | Scanner | Site |\n| --- | --- | --- |'; + const rows = occurrences.map(occurrence => `| ${cell(deviceLabel(deviceById.get(occurrence.deviceId), occurrence.deviceId))} | ${cell(occurrence.sensors.join(', '))} | ${cell(occurrence.sites.join(', '))} |`); + return [header, ...rows].join('\n'); +} + +function cveCandidate( document: DecodedSourceDocument, - occurrence: ReturnType['occurrences'][number], - device: DefenderDevice + cve: DefenderCve, + occurrences: DefenderOccurrence[], + deviceById: Map ): FindingCandidate { - const first = occurrence.observations[0]; - const severityObservation = occurrence.observations.find(observation => observation.severity === occurrence.severity) ?? first; + const allObservations = occurrences.flatMap(occurrence => occurrence.observations); + const first = allObservations[0]; + const severityObservation = allObservations.find(observation => observation.severity === cve.severity) ?? first; const source = { fileName: document.file.name, tableIndex: first.tableIndex, rowIndex: first.rowIndex }; - const observations = occurrence.observations.map(observationValue); + const observations = allObservations.map(observationValue); + const devicesTable = affectedDevicesTable(occurrences, deviceById); return { version: 1, - id: `defender-occurrence-${first.tableIndex}-${first.rowIndex}`, + id: `defender-cve-${cve.id}`, source, fields: [ - field('cve', 'CVE', occurrence.cve, occurrence.cve, source), - field('device-identity', 'Device Identity', occurrence.deviceId, occurrence.deviceId, source), - field('identity-method', 'Identity Method', device.identityMethod, device.identityMethod, source), - field('identity-confidence', 'Identity Confidence', device.identityConfidence, device.identityConfidence, source), - field('identity-provenance', 'Identity Provenance', device.identityProvenance, device.identityProvenance, source), - field('name', 'Name', first.name, first.name, source), - field('ip', 'IP', first.ip, first.ip, source), - field('site', 'Site', first.site, first.site, source), - field('reporting-sensors', 'Reporting Sensors', occurrence.sensors, occurrence.sensors.join(', '), source), + field('cve', 'CVE', cve.id, cve.id, source), + field('affected-device-count', 'Affected Device Count', cve.deviceIds.length, String(cve.deviceIds.length), source), field('severity', 'Severity', severityObservation.severitySource, severityObservation.severitySource, source), - field('description', 'Beschreibung', occurrence.description, occurrence.description, source), + field('description', 'Description', cve.description, cve.description, source), field('observation-count', 'Observation Count', observations.length, String(observations.length), source), + field('affected-devices', 'Affected Devices', devicesTable, devicesTable, source), field('source-observations', 'Source Observations', observations, JSON.stringify(observations), source) ] }; @@ -95,13 +114,14 @@ export const defenderForIotSourceExtractor: SourceExtractor = { }, extract(document) { const model = buildDefenderVulnerabilityModel(sourceTables(document)); + const deviceById = new Map(model.devices.map(device => [device.id, device])); + const occurrencesByCve = new Map(); + for (const occurrence of model.occurrences) { + occurrencesByCve.set(occurrence.cve, [...(occurrencesByCve.get(occurrence.cve) ?? []), occurrence]); + } return { version: 1, - candidates: model.occurrences.map(occurrence => occurrenceCandidate( - document, - occurrence, - model.devices.find(device => device.id === occurrence.deviceId)! - )), + candidates: model.cves.map(cve => cveCandidate(document, cve, occurrencesByCve.get(cve.id) ?? [], deviceById)), diagnostics: model.diagnostics.map(diagnostic => ({ severity: diagnostic.severity, code: diagnostic.code, diff --git a/src/profiles/defender-for-iot/defender-for-iot-definition.ts b/src/profiles/defender-for-iot/defender-for-iot-definition.ts index a4cc887..8839265 100644 --- a/src/profiles/defender-for-iot/defender-for-iot-definition.ts +++ b/src/profiles/defender-for-iot/defender-for-iot-definition.ts @@ -44,12 +44,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = { { role: 'rule-id', headers: [{ pattern: /^CVE$/i, contribution: 1, message: 'CVE is the source-native finding identifier.' }] }, { role: 'description', headers: [{ pattern: /^(?:Beschreibung|Description)$/i, contribution: 1, message: 'The field contains the CVE description.' }] }, { role: 'severity', headers: [{ pattern: /^Severity$/i, contribution: 1, message: 'The field contains the vulnerability severity.' }] }, - { role: 'asset', headers: [ - { pattern: /^Device Identity$/i, contribution: 1, message: 'The extractor supplied the normalized device identity.' }, - { pattern: /^Name$/i, contribution: 0.9, message: 'Name identifies the source asset.' } - ] }, - { role: 'category', headers: [{ pattern: /^Site$/i, contribution: 0.92, message: 'Site groups affected assets.' }] }, - { role: 'score', headers: [{ pattern: /^TotalCVEs$/i, contribution: 0.92, message: 'TotalCVEs is the source asset total.' }] }, + { role: 'asset', headers: [{ pattern: /^Affected Device Count$/i, contribution: 0.92, message: 'The aggregated affected-device count stands in for the affected object of this CVE.' }] }, { role: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] } ], normalization: { severity: commonSeverityMappings, status: commonStatusMappings }, @@ -63,7 +58,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = { { value: 'all', label: 'All Vulnerabilities', patterns: [] }, { value: 'deviation', label: 'Vulnerabilities', patterns: [/^critical$/i, /^high$/i, /^medium$/i, /^low$/i] } ], - resultSectionHeaders: [/^Source Observations$/i] + resultSectionHeaders: [/^Affected Devices$/i] }, - comparisonKeys: [['rule-id', 'asset']] + comparisonKeys: [['rule-id']] }; diff --git a/tests/importers/defender-for-iot.test.ts b/tests/importers/defender-for-iot.test.ts index 313e9ed..5a87d20 100644 --- a/tests/importers/defender-for-iot.test.ts +++ b/tests/importers/defender-for-iot.test.ts @@ -114,14 +114,16 @@ describe('Microsoft Defender for IoT vulnerability model', () => { expect(detailOnly.devices[0].severity.low.state).toBe('unavailable'); }); - it('selects the versioned profile and emits one candidate per device and CVE', async () => { + it('selects the versioned profile and emits one candidate per CVE with devices aggregated', async () => { const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ - summary('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', [1, 2, 0, 0]) + summary('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', [1, 2, 0, 0]), + summary('device-b', '10.0.0.2', 'Synthetic site', [1, 0, 0, 0]) ], { header: summaryHeaders }), 'Assets'); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-a', 'CVE-2026-0401', 'Critical'), - detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-b', 'CVE-2026-0401', 'Critical') + detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-b', 'CVE-2026-0401', 'Critical'), + detail('device-b', '10.0.0.2', 'Synthetic site', 'sensor-a', 'CVE-2026-0401', 'Critical') ], { header: detailHeaders }), 'Vulnerabilities'); const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); const imported = await importFile(new File([bytes], 'defender-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles }); @@ -129,10 +131,17 @@ describe('Microsoft Defender for IoT vulnerability model', () => { expect(imported.profileId).toBe('defender-for-iot-vulnerabilities'); expect(imported.profileVersion).toBe('1.0.0'); expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' }); + // Two devices share one CVE: the Work view must show a single aggregated finding, not one row per device. expect(imported.extraction?.candidates).toHaveLength(1); - expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(2); + expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(3); + expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Device Count')?.originalValue).toBe(2); + // The affected devices are carried as a markdown table for the result popup, one row per device with its scanner and site. + const devicesTable = imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Devices')?.decodedValue ?? ''; + expect(devicesTable).toContain('| Affected Device | Scanner | Site |'); + expect(devicesTable).toContain('00:11:22:33:44:55'); + expect(devicesTable).toContain('device-b'); expect(imported.mapping?.candidates).toHaveLength(1); - expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([1, 2]); + expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([2, 3]); const entry = confirmEntryMappings(previewEntryFromDataset(imported, { sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified @@ -149,11 +158,14 @@ describe('Microsoft Defender for IoT vulnerability model', () => { expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] }); expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingOccurrences).toHaveLength(1); - expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toContain('mac:001122334455'); + // The aggregated CVE carries its affected-device count as the affected asset (two devices here); the device list lives in the evidence table. + expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toBe('2'); + expect(canonical.findingOccurrences[0].score).toBeUndefined(); expect(canonical.evidence).toHaveLength(1); expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) }); - expect(canonical.evidence[0].value).toHaveLength(2); - expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(1); + expect(canonical.evidence[0].value).toHaveLength(3); + // Both asset-summary rows stay preserved as source data alongside the single aggregated finding. + expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(2); }); it('keeps a summary-only import viewable without creating finding definitions or occurrences', async () => {