feat(defender-for-iot): add dataset model, source extractor, and profile on Michael's base
Adapted pick of 7d3ef5f. The profile declares the now mandatory
securityScoring, priorityBasis and timeslotComparison. Client Info adds
Defender as a third tab beside Overview and AI Overview, and a
Defender-only timeslot starts in the Defender view. recordTitleFor only
replaces the title candidate; the Maester and CIS title rules stay.
Left out: the .gitignore entry, --tile-inset, the stored-profile
precedence Michael's fileProfile already has, and the old processing
version assertion.
This commit is contained in:
parent
d376ae5f39
commit
f5a7828883
|
|
@ -3652,6 +3652,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
|
|||
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
|
||||
|
||||
| Datum | Änderung |
|
||||
| 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. |
|
||||
| 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:<technische Setting-ID>`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Deklarative Toolprofile
|
||||
|
||||
Stand: 2026-07-17
|
||||
Stand: 2026-09-10
|
||||
Profilschema: 1
|
||||
|
||||
## Zweck
|
||||
|
|
@ -32,6 +32,7 @@ Das Schema steht in `src/domain/tool-profile-definition.ts`. Die gemeinsame Lauf
|
|||
| PingCastle | 1.0.0 | Healthcheck, Risk Scores und Regelkennungen |
|
||||
| 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.0.0 | Asset-Summary, CVE-Details, Device-Identitaet und Sensorprovenienz |
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -64,6 +65,8 @@ Ein neues Werkzeug benötigt:
|
|||
|
||||
Formatparser, Erkennungsalgorithmus und React-Komponenten müssen dafür nicht geändert werden.
|
||||
|
||||
Microsoft Defender for IoT verwendet weiterhin den generischen Excel-/CSV-Formatadapter. Ein kleiner Source Extractor verbindet Summary- und Detailtabellen, erzeugt genau ein Finding-Vorkommen je normalisiertem Device und CVE und bewahrt 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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,469 @@
|
|||
import type { JsonValue } from './canonical-model';
|
||||
|
||||
export const DEFENDER_FOR_IOT_PROFILE_ID = 'defender-for-iot-vulnerabilities';
|
||||
|
||||
export type DefenderSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
export type DefenderDetailState = 'complete' | 'aggregate-only' | 'detail-only' | 'mismatch' | 'zero' | 'unavailable';
|
||||
export type DefenderIdentityMethod = 'mac' | 'ip' | 'name' | 'unresolved';
|
||||
|
||||
export interface DefenderSourceRow {
|
||||
tableIndex: number;
|
||||
rowIndex: number;
|
||||
fields: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface DefenderSourceTable {
|
||||
tableIndex: number;
|
||||
name: string;
|
||||
headers: string[];
|
||||
rows: DefenderSourceRow[];
|
||||
}
|
||||
|
||||
export interface DefenderDiagnostic {
|
||||
severity: 'info' | 'warning';
|
||||
code: string;
|
||||
message: string;
|
||||
tableIndex?: number;
|
||||
rowIndex?: number;
|
||||
details?: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface DefenderObservation {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
cve: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
site: string;
|
||||
sensor: string;
|
||||
severity: DefenderSeverity | 'unknown';
|
||||
severitySource: string;
|
||||
description: string;
|
||||
additionalSourceFields: Record<string, JsonValue>;
|
||||
tableIndex: number;
|
||||
rowIndex: number;
|
||||
sourceFields: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface DefenderOccurrence {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
cve: string;
|
||||
severity: DefenderSeverity | 'unknown';
|
||||
description: string;
|
||||
sensors: string[];
|
||||
sites: string[];
|
||||
observations: DefenderObservation[];
|
||||
}
|
||||
|
||||
export interface DefenderSeverityMeasure {
|
||||
detail: number;
|
||||
summary?: number;
|
||||
aggregateOnly: number;
|
||||
state: DefenderDetailState;
|
||||
}
|
||||
|
||||
export interface DefenderDevice {
|
||||
id: string;
|
||||
identityMethod: DefenderIdentityMethod;
|
||||
identityConfidence: 'high' | 'medium' | 'low';
|
||||
identityProvenance: string;
|
||||
displayName: string;
|
||||
ip: string;
|
||||
site: string;
|
||||
sensors: string[];
|
||||
totalCves?: number;
|
||||
severity: Record<DefenderSeverity, DefenderSeverityMeasure>;
|
||||
occurrenceIds: string[];
|
||||
summaryRowCount: number;
|
||||
summaryConflict: boolean;
|
||||
}
|
||||
|
||||
export interface DefenderCve {
|
||||
id: string;
|
||||
severity: DefenderSeverity | 'unknown';
|
||||
description: string;
|
||||
deviceIds: string[];
|
||||
sites: string[];
|
||||
sensors: string[];
|
||||
observationCount: number;
|
||||
conflictingSeverity: boolean;
|
||||
conflictingDescription: boolean;
|
||||
}
|
||||
|
||||
export interface DefenderSite {
|
||||
id: string;
|
||||
name: string;
|
||||
assetCount: number;
|
||||
uniqueCveCount: number;
|
||||
affectedDeviceCount: number;
|
||||
occurrences: Record<DefenderSeverity, number>;
|
||||
aggregateOnly: Record<DefenderSeverity, number>;
|
||||
rawObservationCount: number;
|
||||
deviceIds: string[];
|
||||
detailCompleteness: string;
|
||||
}
|
||||
|
||||
export interface DefenderVulnerabilityModel {
|
||||
available: boolean;
|
||||
summaryTableCount: number;
|
||||
detailTableCount: number;
|
||||
summaryRowCount: number;
|
||||
devices: DefenderDevice[];
|
||||
cves: DefenderCve[];
|
||||
occurrences: DefenderOccurrence[];
|
||||
observations: DefenderObservation[];
|
||||
sites: DefenderSite[];
|
||||
aggregateOnlyCount: number;
|
||||
diagnostics: DefenderDiagnostic[];
|
||||
}
|
||||
|
||||
const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low'];
|
||||
const summaryHeaders = ['name', 'ip', 'site', 'sensor', 'totalcves', 'critical', 'high', 'medium', 'low'];
|
||||
const detailHeaders = ['name', 'ip', 'site', 'sensor', 'cve', 'severity', 'beschreibung'];
|
||||
|
||||
function text(value: JsonValue | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function normalizedHeader(value: string): string {
|
||||
return value.trim().toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function normalizedText(value: string): string {
|
||||
return value.trim().toLocaleLowerCase('en-US').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function value(row: DefenderSourceRow, header: string): JsonValue | undefined {
|
||||
const target = normalizedHeader(header);
|
||||
const match = Object.keys(row.fields).find(key => normalizedHeader(key) === target);
|
||||
return match === undefined ? undefined : row.fields[match];
|
||||
}
|
||||
|
||||
function numeric(value: JsonValue | undefined): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value));
|
||||
const parsed = Number(text(value));
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : undefined;
|
||||
}
|
||||
|
||||
function isMac(value: string): boolean {
|
||||
return /^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)
|
||||
|| /^[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function isIpv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
return parts.length === 4 && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
||||
}
|
||||
|
||||
function isIpv6(value: string): boolean {
|
||||
return value.includes(':') && /^[0-9a-f:]+$/i.test(value)
|
||||
&& value.split(':').filter(Boolean).length <= 8;
|
||||
}
|
||||
|
||||
function severity(value: string): DefenderSeverity | 'unknown' {
|
||||
const normalized = normalizedText(value);
|
||||
return severities.find(item => item === normalized) ?? 'unknown';
|
||||
}
|
||||
|
||||
export function defenderTableRole(headersInput: string[]): 'summary' | 'detail' | undefined {
|
||||
const headers = new Set(headersInput.map(normalizedHeader));
|
||||
if (detailHeaders.every(header => headers.has(header))) return 'detail';
|
||||
if (summaryHeaders.every(header => headers.has(header))) return 'summary';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface IdentitySeed {
|
||||
method: DefenderIdentityMethod;
|
||||
base: string;
|
||||
site: string;
|
||||
displayName: string;
|
||||
ip: string;
|
||||
provenance: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
function identitySeed(row: DefenderSourceRow): IdentitySeed {
|
||||
const displayName = text(value(row, 'Name'));
|
||||
const ip = text(value(row, 'IP'));
|
||||
const site = text(value(row, 'Site'));
|
||||
const macValue = [displayName, ip].find(isMac);
|
||||
if (macValue) return { method: 'mac', base: normalizedText(macValue).replace(/[:-]/g, ''), site, displayName, ip, provenance: macValue === displayName ? 'valid MAC from Name' : 'valid MAC from IP', confidence: 'high' };
|
||||
const ipValue = [ip, displayName].find(item => isIpv4(item) || isIpv6(item));
|
||||
if (ipValue) return { method: 'ip', base: normalizedText(ipValue), site, displayName, ip, provenance: ipValue === ip ? 'valid address from IP' : 'valid address from Name', confidence: 'medium' };
|
||||
if (displayName && site) return { method: 'name', base: normalizedText(displayName), site, displayName, ip, provenance: 'normalized Name within Site', confidence: 'medium' };
|
||||
return { method: 'unresolved', base: `${row.tableIndex}:${row.rowIndex}`, site, displayName, ip, provenance: 'insufficient stable identity fields', confidence: 'low' };
|
||||
}
|
||||
|
||||
function identityKeys(rows: DefenderSourceRow[], diagnostics: DefenderDiagnostic[]): Map<DefenderSourceRow, IdentitySeed & { id: string }> {
|
||||
const seeds = rows.map(row => ({ row, seed: identitySeed(row) }));
|
||||
const sitesByBase = new Map<string, Set<string>>();
|
||||
for (const { seed } of seeds) {
|
||||
if (seed.method === 'unresolved') continue;
|
||||
const key = `${seed.method}:${seed.base}`;
|
||||
const sites = sitesByBase.get(key) ?? new Set<string>();
|
||||
if (seed.site) sites.add(normalizedText(seed.site));
|
||||
sitesByBase.set(key, sites);
|
||||
}
|
||||
const warned = new Set<string>();
|
||||
const result = new Map<DefenderSourceRow, IdentitySeed & { id: string }>();
|
||||
for (const { row, seed } of seeds) {
|
||||
const collisionKey = `${seed.method}:${seed.base}`;
|
||||
const reusedAcrossSites = (sitesByBase.get(collisionKey)?.size ?? 0) > 1;
|
||||
const siteScoped = seed.method === 'name' || reusedAcrossSites;
|
||||
const id = seed.method === 'unresolved'
|
||||
? `unresolved:${seed.base}`
|
||||
: `${collisionKey}${siteScoped ? `|site:${normalizedText(seed.site) || 'missing'}` : ''}`;
|
||||
result.set(row, { ...seed, id });
|
||||
if (seed.method === 'unresolved') diagnostics.push({
|
||||
severity: 'warning', code: 'defender-device-identity-unresolved',
|
||||
message: 'A source row has no unambiguous MAC, IP, or site-scoped device name.',
|
||||
tableIndex: row.tableIndex, rowIndex: row.rowIndex
|
||||
});
|
||||
if (reusedAcrossSites && !warned.has(collisionKey)) {
|
||||
warned.add(collisionKey);
|
||||
diagnostics.push({ severity: 'warning', code: 'defender-device-identity-site-scoped', message: 'A device identifier is reused across sites and was kept site-scoped.', details: { identityMethod: seed.method } });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function emptyCounts(): Record<DefenderSeverity, number> {
|
||||
return { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
}
|
||||
|
||||
function detailState(summary: number | undefined, detail: number, severityHasDetail: boolean): DefenderSeverityMeasure {
|
||||
if (summary === undefined) return { detail, aggregateOnly: 0, state: detail > 0 ? 'detail-only' : 'unavailable' };
|
||||
if (summary === 0 && detail === 0) return { detail, summary, aggregateOnly: 0, state: 'zero' };
|
||||
if (summary > 0 && detail === 0) return { detail, summary, aggregateOnly: summary, state: severityHasDetail ? 'mismatch' : 'aggregate-only' };
|
||||
if (summary === detail) return { detail, summary, aggregateOnly: 0, state: 'complete' };
|
||||
return { detail, summary, aggregateOnly: Math.max(0, summary - detail), state: 'mismatch' };
|
||||
}
|
||||
|
||||
export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]): DefenderVulnerabilityModel {
|
||||
const diagnostics: DefenderDiagnostic[] = [];
|
||||
const summaryTables = tables.filter(table => defenderTableRole(table.headers) === 'summary');
|
||||
const detailTables = tables.filter(table => defenderTableRole(table.headers) === 'detail');
|
||||
const summaryRows = summaryTables.flatMap(table => table.rows);
|
||||
const detailRows = detailTables.flatMap(table => table.rows);
|
||||
const allRows = [...summaryRows, ...detailRows];
|
||||
const identities = identityKeys(allRows, diagnostics);
|
||||
|
||||
for (const row of summaryRows) {
|
||||
const total = numeric(value(row, 'TotalCVEs'));
|
||||
const components = severities.map(item => numeric(value(row, item)));
|
||||
if (total !== undefined && components.every(item => item !== undefined)
|
||||
&& total !== components.reduce((sum, item) => sum + (item ?? 0), 0)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning', code: 'defender-summary-total-mismatch',
|
||||
message: 'TotalCVEs does not equal Critical + High + Medium + Low for an asset summary row.',
|
||||
tableIndex: row.tableIndex, rowIndex: row.rowIndex,
|
||||
details: { total, componentTotal: components.reduce((sum, item) => sum + (item ?? 0), 0) }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rowsByIdentity = new Map<string, DefenderSourceRow[]>();
|
||||
for (const row of allRows) {
|
||||
const id = identities.get(row)!.id;
|
||||
rowsByIdentity.set(id, [...(rowsByIdentity.get(id) ?? []), row]);
|
||||
}
|
||||
for (const [deviceId, rows] of rowsByIdentity) {
|
||||
const names = new Set(rows.map(row => normalizedText(text(value(row, 'Name')))).filter(Boolean));
|
||||
const ips = new Set(rows.map(row => normalizedText(text(value(row, 'IP')))).filter(Boolean));
|
||||
if (names.size > 1 || ips.size > 1) diagnostics.push({
|
||||
severity: 'warning', code: 'defender-device-identity-ambiguous',
|
||||
message: 'Rows grouped under one normalized device contain conflicting source identity values and require review.',
|
||||
details: { deviceId, nameVariantCount: names.size, ipVariantCount: ips.size }
|
||||
});
|
||||
}
|
||||
|
||||
for (const table of detailTables) {
|
||||
const nullHeader = table.headers.find(header => normalizedHeader(header) === 'null');
|
||||
if (!nullHeader) continue;
|
||||
const populated = table.rows.filter(row => text(value(row, nullHeader))).length;
|
||||
if (populated) diagnostics.push({
|
||||
severity: 'warning', code: 'defender-null-column-populated',
|
||||
message: 'The source column named NULL contains values; they were preserved as additional source fields.',
|
||||
tableIndex: table.tableIndex, details: { populatedRows: populated }
|
||||
});
|
||||
}
|
||||
|
||||
const observations: DefenderObservation[] = detailRows.flatMap(row => {
|
||||
const cve = text(value(row, 'CVE')).toLocaleUpperCase('en-US');
|
||||
const identity = identities.get(row)!;
|
||||
if (!cve) {
|
||||
diagnostics.push({ severity: 'warning', code: 'defender-cve-missing', message: 'A detail row without a CVE identifier was preserved as source data but not made actionable.', tableIndex: row.tableIndex, rowIndex: row.rowIndex });
|
||||
return [];
|
||||
}
|
||||
const severitySource = text(value(row, 'Severity'));
|
||||
const additionalSourceFields: Record<string, JsonValue> = {};
|
||||
for (const [label, fieldValue] of Object.entries(row.fields)) {
|
||||
if (normalizedHeader(label) === 'null' && text(fieldValue)) additionalSourceFields[label] = fieldValue;
|
||||
}
|
||||
return [{
|
||||
id: `observation-${row.tableIndex}-${row.rowIndex}`,
|
||||
deviceId: identity.id,
|
||||
cve,
|
||||
name: text(value(row, 'Name')),
|
||||
ip: text(value(row, 'IP')),
|
||||
site: text(value(row, 'Site')),
|
||||
sensor: text(value(row, 'Sensor')),
|
||||
severity: severity(severitySource),
|
||||
severitySource,
|
||||
description: text(value(row, 'Beschreibung')),
|
||||
additionalSourceFields,
|
||||
tableIndex: row.tableIndex,
|
||||
rowIndex: row.rowIndex,
|
||||
sourceFields: row.fields
|
||||
}];
|
||||
});
|
||||
|
||||
const duplicateObservationGroups = new Map<string, DefenderObservation[]>();
|
||||
for (const observation of observations) {
|
||||
const key = [observation.deviceId, observation.cve, normalizedText(observation.sensor), normalizedText(observation.severitySource), normalizedText(observation.description)].join('|');
|
||||
duplicateObservationGroups.set(key, [...(duplicateObservationGroups.get(key) ?? []), observation]);
|
||||
}
|
||||
const duplicateRows = [...duplicateObservationGroups.values()].reduce((sum, group) => sum + Math.max(0, group.length - 1), 0);
|
||||
if (duplicateRows) diagnostics.push({ severity: 'warning', code: 'defender-duplicate-source-observation', message: 'Duplicate source observations were retained but do not increase actionable occurrence counts.', details: { duplicateRows } });
|
||||
|
||||
const observationGroups = new Map<string, DefenderObservation[]>();
|
||||
for (const observation of observations) {
|
||||
const key = `${observation.deviceId}|${observation.cve}`;
|
||||
observationGroups.set(key, [...(observationGroups.get(key) ?? []), observation]);
|
||||
}
|
||||
const occurrences: DefenderOccurrence[] = [...observationGroups.entries()].map(([id, group]) => ({
|
||||
id,
|
||||
deviceId: group[0].deviceId,
|
||||
cve: group[0].cve,
|
||||
severity: group.find(item => item.severity !== 'unknown')?.severity ?? 'unknown',
|
||||
description: group.find(item => item.description)?.description ?? '',
|
||||
sensors: [...new Set(group.map(item => item.sensor).filter(Boolean))].sort(),
|
||||
sites: [...new Set(group.map(item => item.site).filter(Boolean))].sort(),
|
||||
observations: group
|
||||
})).sort((left, right) => left.cve.localeCompare(right.cve) || left.deviceId.localeCompare(right.deviceId));
|
||||
|
||||
const cveGroups = new Map<string, DefenderOccurrence[]>();
|
||||
for (const occurrence of occurrences) cveGroups.set(occurrence.cve, [...(cveGroups.get(occurrence.cve) ?? []), occurrence]);
|
||||
const cves: DefenderCve[] = [...cveGroups.entries()].map(([id, group]) => {
|
||||
const all = group.flatMap(item => item.observations);
|
||||
const severityValues = [...new Set(all.map(item => item.severitySource).filter(Boolean).map(normalizedText))];
|
||||
const descriptions = [...new Set(all.map(item => item.description).filter(Boolean).map(normalizedText))];
|
||||
const conflictingSeverity = severityValues.length > 1;
|
||||
const conflictingDescription = descriptions.length > 1;
|
||||
if (conflictingSeverity) diagnostics.push({ severity: 'warning', code: 'defender-cve-severity-conflict', message: 'A CVE has conflicting source severity values.', details: { cve: id, variantCount: severityValues.length } });
|
||||
if (conflictingDescription) diagnostics.push({ severity: 'warning', code: 'defender-cve-description-conflict', message: 'A CVE has conflicting source descriptions.', details: { cve: id, variantCount: descriptions.length } });
|
||||
return {
|
||||
id,
|
||||
severity: all.find(item => item.severity !== 'unknown')?.severity ?? 'unknown',
|
||||
description: all.find(item => item.description)?.description ?? '',
|
||||
deviceIds: [...new Set(group.map(item => item.deviceId))].sort(),
|
||||
sites: [...new Set(all.map(item => item.site).filter(Boolean))].sort(),
|
||||
sensors: [...new Set(all.map(item => item.sensor).filter(Boolean))].sort(),
|
||||
observationCount: all.length,
|
||||
conflictingSeverity,
|
||||
conflictingDescription
|
||||
};
|
||||
}).sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
const summaryByDevice = new Map<string, DefenderSourceRow[]>();
|
||||
for (const row of summaryRows) {
|
||||
const id = identities.get(row)!.id;
|
||||
summaryByDevice.set(id, [...(summaryByDevice.get(id) ?? []), row]);
|
||||
}
|
||||
for (const [deviceId, rows] of summaryByDevice) {
|
||||
if (rows.length < 2) continue;
|
||||
const signatures = new Set(rows.map(row => [numeric(value(row, 'TotalCVEs')), ...severities.map(item => numeric(value(row, item)))].join('|')));
|
||||
diagnostics.push({
|
||||
severity: 'warning', code: signatures.size > 1 ? 'defender-summary-conflict' : 'defender-summary-duplicate',
|
||||
message: signatures.size > 1 ? 'A normalized device has conflicting asset-summary rows.' : 'A duplicate asset-summary row was retained without double-counting.',
|
||||
details: { deviceId, rowCount: rows.length }
|
||||
});
|
||||
}
|
||||
|
||||
const occurrenceByDevice = new Map<string, DefenderOccurrence[]>();
|
||||
for (const occurrence of occurrences) occurrenceByDevice.set(occurrence.deviceId, [...(occurrenceByDevice.get(occurrence.deviceId) ?? []), occurrence]);
|
||||
const severitiesWithDetail = new Set(occurrences.map(occurrence => occurrence.severity).filter((item): item is DefenderSeverity => item !== 'unknown'));
|
||||
const deviceIds = new Set([...summaryByDevice.keys(), ...occurrenceByDevice.keys()]);
|
||||
const devices: DefenderDevice[] = [...deviceIds].map(id => {
|
||||
const summaryGroup = summaryByDevice.get(id) ?? [];
|
||||
const detailGroup = occurrenceByDevice.get(id) ?? [];
|
||||
const representativeRow = summaryGroup[0] ?? detailRows.find(row => identities.get(row)?.id === id)!;
|
||||
const identity = identities.get(representativeRow)!;
|
||||
const summaryRow = summaryGroup[0];
|
||||
const detailCounts = emptyCounts();
|
||||
for (const occurrence of detailGroup) if (occurrence.severity !== 'unknown') detailCounts[occurrence.severity] += 1;
|
||||
const summaryCounts = Object.fromEntries(severities.map(item => [item, summaryRow ? numeric(value(summaryRow, item)) : undefined])) as Record<DefenderSeverity, number | undefined>;
|
||||
const measures = Object.fromEntries(severities.map(item => [item, detailState(summaryCounts[item], detailCounts[item], severitiesWithDetail.has(item))])) as Record<DefenderSeverity, DefenderSeverityMeasure>;
|
||||
const sourceRows = [...summaryGroup, ...detailRows.filter(row => identities.get(row)?.id === id)];
|
||||
return {
|
||||
id,
|
||||
identityMethod: identity.method,
|
||||
identityConfidence: identity.confidence,
|
||||
identityProvenance: identity.provenance,
|
||||
displayName: text(value(representativeRow, 'Name')),
|
||||
ip: text(value(representativeRow, 'IP')),
|
||||
site: text(value(representativeRow, 'Site')),
|
||||
sensors: [...new Set(sourceRows.map(row => text(value(row, 'Sensor'))).filter(Boolean))].sort(),
|
||||
totalCves: summaryRow ? numeric(value(summaryRow, 'TotalCVEs')) : undefined,
|
||||
severity: measures,
|
||||
occurrenceIds: detailGroup.map(item => item.id),
|
||||
summaryRowCount: summaryGroup.length,
|
||||
summaryConflict: summaryGroup.length > 1 && new Set(summaryGroup.map(row => [numeric(value(row, 'TotalCVEs')), ...severities.map(item => numeric(value(row, item)))].join('|'))).size > 1
|
||||
};
|
||||
}).sort((left, right) => left.site.localeCompare(right.site) || left.displayName.localeCompare(right.displayName));
|
||||
|
||||
for (const device of devices) {
|
||||
for (const item of severities) {
|
||||
if (device.severity[item].state === 'mismatch' || device.severity[item].state === 'detail-only') diagnostics.push({
|
||||
severity: 'warning', code: 'defender-summary-detail-mismatch',
|
||||
message: 'A device severity total does not reconcile between summary and detailed occurrences.',
|
||||
details: { deviceId: device.id, severity: item, summary: device.severity[item].summary ?? null, detail: device.severity[item].detail }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const siteNames = new Set(devices.map(device => device.site || 'Site unavailable'));
|
||||
const sites: DefenderSite[] = [...siteNames].map(name => {
|
||||
const siteDevices = devices.filter(device => (device.site || 'Site unavailable') === name);
|
||||
const ids = new Set(siteDevices.map(device => device.id));
|
||||
const siteOccurrences = occurrences.filter(item => ids.has(item.deviceId));
|
||||
const occurrenceCounts = emptyCounts();
|
||||
for (const occurrence of siteOccurrences) if (occurrence.severity !== 'unknown') occurrenceCounts[occurrence.severity] += 1;
|
||||
const aggregateOnly = emptyCounts();
|
||||
for (const device of siteDevices) for (const item of severities) aggregateOnly[item] += device.severity[item].aggregateOnly;
|
||||
const states = siteDevices.flatMap(device => severities.map(item => device.severity[item].state));
|
||||
const detailCompleteness = states.includes('mismatch') ? 'Mismatch'
|
||||
: states.includes('aggregate-only') ? 'Aggregate-only detail'
|
||||
: states.includes('unavailable') ? 'Detail unavailable'
|
||||
: 'Detailed';
|
||||
return {
|
||||
id: normalizedText(name), name,
|
||||
assetCount: siteDevices.length,
|
||||
uniqueCveCount: new Set(siteOccurrences.map(item => item.cve)).size,
|
||||
affectedDeviceCount: siteDevices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length,
|
||||
occurrences: occurrenceCounts,
|
||||
aggregateOnly,
|
||||
rawObservationCount: observations.filter(item => ids.has(item.deviceId)).length,
|
||||
deviceIds: siteDevices.map(item => item.id),
|
||||
detailCompleteness
|
||||
};
|
||||
}).sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
available: summaryTables.length > 0 || detailTables.length > 0,
|
||||
summaryTableCount: summaryTables.length,
|
||||
detailTableCount: detailTables.length,
|
||||
summaryRowCount: summaryRows.length,
|
||||
devices,
|
||||
cves,
|
||||
occurrences,
|
||||
observations,
|
||||
sites,
|
||||
aggregateOnlyCount: devices.reduce((sum, device) => sum + severities.reduce((inner, item) => inner + device.severity[item].aggregateOnly, 0), 0),
|
||||
diagnostics
|
||||
};
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ export type ProcessingReadinessStatus =
|
|||
| 'unknown';
|
||||
|
||||
/** Globaler, in der Oberflaeche sichtbarer Stand des ASSESSOR-Verarbeitungsvertrags. */
|
||||
export const CURRENT_ASSESSOR_PROCESSING_VERSION = '1.3.0';
|
||||
export const CURRENT_ASSESSOR_PROCESSING_VERSION = '1.4.0';
|
||||
|
||||
export interface ProcessingVersionManifest extends ToolProcessingContract {
|
||||
profileId: string;
|
||||
|
|
@ -24,6 +24,7 @@ export interface ProcessingVersionManifest extends ToolProcessingContract {
|
|||
*/
|
||||
export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] = [
|
||||
{ profileId: 'generic', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'generic-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true },
|
||||
{ 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: 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 },
|
||||
|
|
@ -46,6 +47,10 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
|
|||
const manifestKey = (profileId: string, profileVersion: string) => `${profileId.trim().toLocaleLowerCase('en-US')}@${profileVersion.trim()}`;
|
||||
const manifestLookup = new Map(PROCESSING_VERSION_MANIFESTS.map(item => [manifestKey(item.profileId, item.profileVersion), item]));
|
||||
|
||||
function recordedProfileId(profileId: string | undefined, processedWith: ProcessingVersion): string | undefined {
|
||||
return processedWith.profileId?.trim() || profileId?.trim();
|
||||
}
|
||||
|
||||
export function processingManifest(profileId: string | undefined, profileVersion: string | undefined): ProcessingVersionManifest | undefined {
|
||||
if (!profileId || !profileVersion) return undefined;
|
||||
return manifestLookup.get(manifestKey(profileId, profileVersion));
|
||||
|
|
@ -53,16 +58,20 @@ export function processingManifest(profileId: string | undefined, profileVersion
|
|||
|
||||
export function currentProcessingManifest(profileId: string | undefined): ProcessingVersionManifest | undefined {
|
||||
if (!profileId) return undefined;
|
||||
return PROCESSING_VERSION_MANIFESTS.find(item => item.current && item.profileId === profileId);
|
||||
const normalized = profileId.trim().toLocaleLowerCase('en-US');
|
||||
return PROCESSING_VERSION_MANIFESTS.find(item => item.current
|
||||
&& item.profileId.toLocaleLowerCase('en-US') === normalized);
|
||||
}
|
||||
|
||||
export function resolvedCompatibilityKey(profileId: string | undefined, processedWith: ProcessingVersion): string | undefined {
|
||||
return processedWith.compatibilityKey ?? processingManifest(profileId ?? processedWith.profileId, processedWith.profileVersion)?.compatibilityKey;
|
||||
return processedWith.compatibilityKey
|
||||
?? processingManifest(recordedProfileId(profileId, processedWith), processedWith.profileVersion)?.compatibilityKey;
|
||||
}
|
||||
|
||||
export function processingReadiness(profileId: string | undefined, processedWith: ProcessingVersion): ProcessingReadinessStatus {
|
||||
const manifest = processingManifest(profileId ?? processedWith.profileId, processedWith.profileVersion);
|
||||
const current = currentProcessingManifest(profileId ?? processedWith.profileId);
|
||||
const effectiveProfileId = recordedProfileId(profileId, processedWith);
|
||||
const manifest = processingManifest(effectiveProfileId, processedWith.profileVersion);
|
||||
const current = currentProcessingManifest(effectiveProfileId);
|
||||
if (!manifest || !current) return 'unknown';
|
||||
const recordedKey = processedWith.compatibilityKey ?? manifest.compatibilityKey;
|
||||
if (recordedKey !== current.compatibilityKey) return 'incompatible';
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ import { dashboardResultState, dashboardScoreFromStates, type DashboardResultSta
|
|||
|
||||
export { dashboardResultState } from './dashboard-result-state';
|
||||
import { AiOverviewPanel, useAiOverview } from './AiOverviewPanel';
|
||||
import { DEFENDER_FOR_IOT_PROFILE_ID } from '../../domain/defender-for-iot';
|
||||
import { DefenderVulnerabilityPanel } from '../defender-for-iot/DefenderVulnerabilityPanel';
|
||||
import { defenderModelForDataset } from '../defender-for-iot/defender-dataset-model';
|
||||
|
||||
const assessorApi = new AssessorApiClient();
|
||||
export { dashboardDomainAssignment, dashboardDomainRecords } from './security-domains';
|
||||
|
|
@ -1410,14 +1413,18 @@ export function dashboardAnalysis(
|
|||
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||
const app = useApp();
|
||||
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
|
||||
const [clientInfoView, setClientInfoView] = useState<'overview' | 'ai'>('overview');
|
||||
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'ai'>('overview');
|
||||
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
|
||||
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
|
||||
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
|
||||
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
|
||||
const showAiOverview = Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run);
|
||||
|
||||
useEffect(() => {
|
||||
if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView('overview');
|
||||
}, [clientInfoView, showAiOverview]);
|
||||
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
|
||||
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview');
|
||||
else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview');
|
||||
}, [clientInfoView, hasDefender, pureDefender, showAiOverview]);
|
||||
const gpoInventory = useGpoInventory(app.targetProjectId);
|
||||
const sourceFilter = app.facetFilters['provenance:source-scan'];
|
||||
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
|
||||
|
|
@ -1470,8 +1477,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
if (!dataset || !activeSheet) return app.targetProjectId
|
||||
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
|
||||
: <EmptyWorkspace />;
|
||||
const clientInfoViews: Array<readonly ['overview' | 'ai', string]> = [
|
||||
['overview', 'Overview'] as const,
|
||||
const clientInfoViews: Array<readonly ['overview' | 'defender' | 'ai', string]> = [
|
||||
...(pureDefender ? [] : [['overview', 'Overview'] as const]),
|
||||
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
|
||||
...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
|
||||
];
|
||||
const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views">
|
||||
|
|
@ -1479,6 +1487,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
|
||||
</nav> : undefined;
|
||||
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
||||
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />;
|
||||
}
|
||||
|
||||
const {
|
||||
total, statusFacet, severityFacet, sourceFacet, tools, reports,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
||||
import { useVirtualRows } from '../../hooks/use-virtual-rows';
|
||||
import { toolIdForRecord } from '../../importers/status-normalization';
|
||||
import { recordTitleFor } from './record-title';
|
||||
|
||||
/**
|
||||
* Geschaetzte Zeilenhoehe fuer die Fenster-Berechnung der Trefferliste
|
||||
|
|
@ -234,7 +235,7 @@ export function DatasetViewer() {
|
|||
if (!dataset || !activeSheet) return <EmptyWorkspace />;
|
||||
|
||||
const recordTitle = (record: FindingRecord) => {
|
||||
const candidate = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title;
|
||||
const candidate = recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset.profileId);
|
||||
const recordToolId = toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId);
|
||||
if (recordToolId === 'maester') return maesterFindingTitle(record, candidate);
|
||||
// CIS headings name their benchmark section instead of the rule number, which stays in Source Rule ID.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { toolIdForRecord } from '../../importers/status-normalization';
|
||||
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
||||
|
||||
function matches(pattern: RegExp, value: string): boolean {
|
||||
pattern.lastIndex = 0;
|
||||
return pattern.test(value.trim());
|
||||
}
|
||||
|
||||
/** Resolves a title with the individual record's profile in mixed project sheets. */
|
||||
export function recordTitleFor(record: FindingRecord, selectedColumn = '', fallbackToolId?: string): string {
|
||||
const toolId = toolIdForRecord(record, fallbackToolId);
|
||||
const titlePatterns = toolProfileDefinitions.find(definition => definition.id === toolId)?.presentation.titleHeaders ?? [];
|
||||
const profileTitle = titlePatterns
|
||||
.map(pattern => record.fields.find(field => field.value && matches(pattern, field.label))?.value)
|
||||
.find(Boolean);
|
||||
return profileTitle
|
||||
?? (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value)
|
||||
?? record.title;
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot';
|
||||
import type { ImportedDataset } from '../../domain/dataset';
|
||||
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||||
import { defenderModelForDataset } from './defender-dataset-model';
|
||||
|
||||
type DefenderView = 'cves' | 'devices' | 'sites';
|
||||
|
||||
const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low'];
|
||||
|
||||
function label(value: string): string {
|
||||
return value ? value.charAt(0).toLocaleUpperCase('en-US') + value.slice(1) : 'Unknown';
|
||||
}
|
||||
|
||||
function Count({ value }: { value: number }) {
|
||||
return <>{value.toLocaleString('en-US')}</>;
|
||||
}
|
||||
|
||||
function Measure({ measure }: { measure: DefenderSeverityMeasure }) {
|
||||
if (measure.state === 'unavailable') return <span className="defender-unavailable">Unavailable</span>;
|
||||
if (measure.state === 'aggregate-only') return <span><Count value={measure.summary ?? 0} /> <small>aggregate-only</small></span>;
|
||||
if (measure.state === 'mismatch') return <span><Count value={measure.detail} /> detailed <small>of {measure.summary?.toLocaleString('en-US') ?? 'unavailable'} summary</small></span>;
|
||||
if (measure.state === 'detail-only') return <span><Count value={measure.detail} /> <small>detail only</small></span>;
|
||||
return <Count value={measure.detail} />;
|
||||
}
|
||||
|
||||
function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' }) {
|
||||
return <span className={`defender-severity severity-${severity}`}><i aria-hidden="true" />{label(severity)}</span>;
|
||||
}
|
||||
|
||||
function DeviceCves({ device, occurrences }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences'] }) {
|
||||
const values = occurrences.filter(item => device.occurrenceIds.includes(item.id));
|
||||
return <div className="defender-drilldown">
|
||||
<h3>{device.displayName || 'Unnamed device'}</h3>
|
||||
<p>{device.ip || 'IP unavailable'} · {device.site || 'Site unavailable'} · Identity: {device.identityProvenance} ({device.identityConfidence})</p>
|
||||
<div className="defender-aggregate-summary" aria-label="Severity detail availability">
|
||||
{severities.map(item => <span key={item}><strong>{label(item)}</strong><Measure measure={device.severity[item]} /></span>)}
|
||||
</div>
|
||||
{values.length ? <table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th><th>Description</th><th>Reporting sensors</th></tr></thead><tbody>
|
||||
{values.map(item => <tr key={item.id}><td>{item.cve}</td><td><SeverityMark severity={item.severity} /></td><td>{item.description || 'Description unavailable'}</td><td>{item.sensors.join(', ') || 'Sensor unavailable'}</td></tr>)}
|
||||
</tbody></table> : <p className="defender-empty">No detailed CVE rows are available for this device. Summary counts remain visible above.</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: ImportedDataset; navigation?: ReactNode }) {
|
||||
const model = useMemo(() => defenderModelForDataset(dataset), [dataset]);
|
||||
const [view, setView] = useState<DefenderView>('cves');
|
||||
const [selectedCve, setSelectedCve] = useState('');
|
||||
const [selectedDevice, setSelectedDevice] = useState('');
|
||||
const [selectedSite, setSelectedSite] = useState('');
|
||||
const currentCve = model.cves.find(item => item.id === selectedCve);
|
||||
const currentDevice = model.devices.find(item => item.id === selectedDevice);
|
||||
const currentSite = model.sites.find(item => item.id === selectedSite);
|
||||
const affectedDevices = model.devices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length;
|
||||
const occurrenceSeverity = Object.fromEntries(severities.map(item => [item, model.occurrences.filter(occurrence => occurrence.severity === item).length])) as Record<DefenderSeverity, number>;
|
||||
|
||||
return <div className="defender-page">
|
||||
{navigation}
|
||||
<p className="defender-context"><strong>Microsoft Defender for IoT vulnerabilities</strong><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span><WorkTimeslotControl /></p>
|
||||
<section className="defender-metrics" aria-label="Vulnerability metrics">
|
||||
<article><span>Assets</span><strong><Count value={model.devices.length} /></strong></article>
|
||||
<article><span>Unique CVEs</span><strong><Count value={model.cves.length} /></strong></article>
|
||||
<article><span>Affected devices</span><strong><Count value={affectedDevices} /></strong></article>
|
||||
<article><span>Device × CVE occurrences</span><strong><Count value={model.occurrences.length} /></strong></article>
|
||||
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article>
|
||||
<article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
|
||||
</section>
|
||||
<nav className="defender-tabs" aria-label="Defender vulnerability views">
|
||||
{([['cves', 'CVEs'], ['devices', 'Devices'], ['sites', 'Sites']] as const).map(([id, title]) => <button type="button" key={id} aria-pressed={view === id} onClick={() => setView(id)}>{title}</button>)}
|
||||
</nav>
|
||||
|
||||
{view === 'cves' && <section className="defender-view" aria-labelledby="defender-cves-heading">
|
||||
<header><h2 id="defender-cves-heading">CVE view</h2><span>{model.cves.length.toLocaleString('en-US')} unique definitions</span></header>
|
||||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th><th>Description</th><th>Affected devices</th><th>Sites</th><th>Sensors</th><th>Observations</th></tr></thead><tbody>
|
||||
{model.cves.map(cve => <tr key={cve.id}><td><button type="button" aria-pressed={selectedCve === cve.id} onClick={() => setSelectedCve(cve.id)}>{cve.id}</button>{(cve.conflictingDescription || cve.conflictingSeverity) && <small className="defender-conflict">Review conflict</small>}</td><td><SeverityMark severity={cve.severity} /></td><td>{cve.description || 'Description unavailable'}</td><td><Count value={cve.deviceIds.length} /></td><td>{cve.sites.join(', ') || 'Site unavailable'}</td><td>{cve.sensors.join(', ') || 'Sensor unavailable'}</td><td><Count value={cve.observationCount} /></td></tr>)}
|
||||
</tbody></table></div>
|
||||
{currentCve && <div className="defender-drilldown"><h3>{currentCve.id}</h3><p>{currentCve.description || 'Description unavailable'}</p>
|
||||
{(currentCve.conflictingDescription || currentCve.conflictingSeverity) && <p className="defender-warning">Conflicting source {currentCve.conflictingDescription && currentCve.conflictingSeverity ? 'descriptions and severity values' : currentCve.conflictingDescription ? 'descriptions' : 'severity values'} require review.</p>}
|
||||
<h4>Affected devices and source observations</h4>
|
||||
{model.occurrences.filter(item => item.cve === currentCve.id).map(occurrence => {
|
||||
const device = model.devices.find(item => item.id === occurrence.deviceId);
|
||||
return <details key={occurrence.id}><summary>{device?.displayName || 'Unnamed device'} · {device?.site || 'Site unavailable'} · {occurrence.observations.length.toLocaleString('en-US')} observation{occurrence.observations.length === 1 ? '' : 's'}</summary>
|
||||
<table className="defender-table"><thead><tr><th>Source row</th><th>Name</th><th>IP</th><th>Site</th><th>Sensor</th><th>Severity</th></tr></thead><tbody>{occurrence.observations.map(observation => <tr key={observation.id}><td>{observation.rowIndex + 1}</td><td>{observation.name || 'Unavailable'}</td><td>{observation.ip || 'Unavailable'}</td><td>{observation.site || 'Unavailable'}</td><td>{observation.sensor || 'Unavailable'}</td><td>{observation.severitySource || 'Unavailable'}</td></tr>)}</tbody></table>
|
||||
</details>;
|
||||
})}
|
||||
</div>}
|
||||
</section>}
|
||||
|
||||
{view === 'devices' && <section className="defender-view" aria-labelledby="defender-devices-heading">
|
||||
<header><h2 id="defender-devices-heading">Device view</h2><span>Detailed and aggregate-only counts stay separate</span></header>
|
||||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Device</th><th>IP</th><th>Site</th><th>Sensors</th>{severities.map(item => <th key={item}>{label(item)}</th>)}</tr></thead><tbody>
|
||||
{model.devices.map(device => <tr key={device.id}><td><button type="button" aria-pressed={selectedDevice === device.id} onClick={() => setSelectedDevice(device.id)}>{device.displayName || 'Unnamed device'}</button></td><td>{device.ip || 'Unavailable'}</td><td>{device.site || 'Unavailable'}</td><td>{device.sensors.join(', ') || 'Unavailable'}</td>{severities.map(item => <td key={item}><Measure measure={device.severity[item]} /></td>)}</tr>)}
|
||||
</tbody></table></div>
|
||||
{currentDevice && <DeviceCves device={currentDevice} occurrences={model.occurrences} />}
|
||||
</section>}
|
||||
|
||||
{view === 'sites' && <section className="defender-view" aria-labelledby="defender-sites-heading">
|
||||
<header><h2 id="defender-sites-heading">Site dashboard</h2><span>Occurrence totals exclude aggregate-only counts</span></header>
|
||||
<section className="defender-severity-strip" aria-label="Detailed occurrence severity distribution">{severities.map(item => <article key={item}><SeverityMark severity={item} /><strong><Count value={occurrenceSeverity[item]} /></strong></article>)}</section>
|
||||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Site</th><th>Assets</th><th>Unique CVEs</th><th>Affected devices</th>{severities.map(item => <th key={item}>{label(item)} occurrences</th>)}<th>Aggregate-only</th><th>Raw observations</th><th>Detail completeness</th></tr></thead><tbody>
|
||||
{model.sites.map(site => <tr key={site.id}><td><button type="button" aria-pressed={selectedSite === site.id} onClick={() => setSelectedSite(site.id)}>{site.name}</button></td><td><Count value={site.assetCount} /></td><td><Count value={site.uniqueCveCount} /></td><td><Count value={site.affectedDeviceCount} /></td>{severities.map(item => <td key={item}><Count value={site.occurrences[item]} /></td>)}<td><Count value={severities.reduce((sum, item) => sum + site.aggregateOnly[item], 0)} /></td><td><Count value={site.rawObservationCount} /></td><td>{site.detailCompleteness}</td></tr>)}
|
||||
</tbody></table></div>
|
||||
{currentSite && <div className="defender-drilldown"><h3>{currentSite.name}</h3><p>Select each device below to follow the site → device → CVE evidence path.</p>{model.devices.filter(device => currentSite.deviceIds.includes(device.id)).map(device => <details key={device.id}><summary>{device.displayName || 'Unnamed device'} · {device.occurrenceIds.length.toLocaleString('en-US')} detailed CVE occurrence{device.occurrenceIds.length === 1 ? '' : 's'}</summary><DeviceCves device={device} occurrences={model.occurrences} /></details>)}</div>}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { JsonValue } from '../../domain/canonical-model';
|
||||
import {
|
||||
buildDefenderVulnerabilityModel,
|
||||
defenderTableRole,
|
||||
type DefenderSourceRow,
|
||||
type DefenderSourceTable,
|
||||
type DefenderVulnerabilityModel
|
||||
} from '../../domain/defender-for-iot';
|
||||
import type { DatasetSheet, ImportedDataset } from '../../domain/dataset';
|
||||
|
||||
function recordFields(sheet: DatasetSheet, recordIndex: number): Record<string, JsonValue> {
|
||||
const record = sheet.analysis.records[recordIndex];
|
||||
return Object.fromEntries(record.fields.map(field => [field.label, field.value]));
|
||||
}
|
||||
|
||||
function parsedObservations(fields: Record<string, JsonValue>): Array<Record<string, JsonValue>> | undefined {
|
||||
const source = Object.entries(fields).find(([label]) => /^Source Observations$/i.test(label.trim()))?.[1];
|
||||
if (typeof source !== 'string' || !source.trim()) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(source);
|
||||
return Array.isArray(parsed) && parsed.every(item => item && typeof item === 'object' && !Array.isArray(item))
|
||||
? parsed as Array<Record<string, JsonValue>>
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function observationRows(sheet: DatasetSheet, tableIndex: number): DefenderSourceRow[] | undefined {
|
||||
const expanded = sheet.analysis.records.flatMap((record, recordIndex) => {
|
||||
const observations = parsedObservations(recordFields(sheet, recordIndex));
|
||||
if (!observations) return [];
|
||||
return observations.map((observation, observationIndex): DefenderSourceRow => {
|
||||
const source = observation.source && typeof observation.source === 'object' && !Array.isArray(observation.source)
|
||||
? observation.source as Record<string, JsonValue>
|
||||
: {};
|
||||
const additional = observation.additionalSourceFields && typeof observation.additionalSourceFields === 'object' && !Array.isArray(observation.additionalSourceFields)
|
||||
? observation.additionalSourceFields as Record<string, JsonValue>
|
||||
: {};
|
||||
return {
|
||||
tableIndex: typeof source.tableIndex === 'number' ? source.tableIndex : tableIndex,
|
||||
rowIndex: typeof source.rowIndex === 'number' ? source.rowIndex : record.index + observationIndex,
|
||||
fields: {
|
||||
Name: observation.name ?? '', IP: observation.ip ?? '', Site: observation.site ?? '', Sensor: observation.sensor ?? '',
|
||||
CVE: observation.cve ?? '', Severity: observation.severity ?? '', NULL: additional.NULL ?? null,
|
||||
Beschreibung: observation.description ?? '', ...additional
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
return expanded.length ? expanded : undefined;
|
||||
}
|
||||
|
||||
function sourceTable(sheet: DatasetSheet, index: number): DefenderSourceTable | undefined {
|
||||
const tableIndex = sheet.sourceTableIndex ?? sheet.workbookIndex ?? index;
|
||||
const expanded = observationRows(sheet, tableIndex);
|
||||
if (expanded) return {
|
||||
tableIndex,
|
||||
name: sheet.sourceSheet ?? sheet.name,
|
||||
headers: ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung'],
|
||||
rows: expanded
|
||||
};
|
||||
if (!defenderTableRole(sheet.analysis.headers)) return undefined;
|
||||
return {
|
||||
tableIndex,
|
||||
name: sheet.sourceSheet ?? sheet.name,
|
||||
headers: sheet.analysis.headers,
|
||||
rows: sheet.analysis.records.map((record, recordIndex) => ({
|
||||
tableIndex,
|
||||
rowIndex: record.index,
|
||||
fields: recordFields(sheet, recordIndex)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function defenderModelForDataset(dataset: ImportedDataset): DefenderVulnerabilityModel {
|
||||
const tables = dataset.sheets
|
||||
.filter(sheet => !sheet.isProjectSheet)
|
||||
.map(sourceTable)
|
||||
.filter((table): table is DefenderSourceTable => Boolean(table));
|
||||
return buildDefenderVulnerabilityModel(tables);
|
||||
}
|
||||
|
||||
|
|
@ -4,6 +4,7 @@ import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
|
|||
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
|
||||
import mitreData from '../../assets/data/mitre-enterprise-v19.2.json';
|
||||
import { convertEnterpriseStix } from './mitre-stix';
|
||||
import { recordTitleFor } from '../dataset-viewer/record-title';
|
||||
|
||||
export interface MitreTechnique {
|
||||
id: string;
|
||||
|
|
@ -162,7 +163,7 @@ export function buildFindingPathItems(records: FindingRecord[], data: MitreGraph
|
|||
const severity = normalizeSeverity(severityValue);
|
||||
const weight = numberField(record, /^weight$/i);
|
||||
const score = numberField(record, /^score$/i);
|
||||
const title = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title;
|
||||
const title = recordTitleFor(record, titleColumn, fallbackToolId);
|
||||
const categories = [...new Set([
|
||||
...record.fields
|
||||
.filter(field => /^category$/i.test(field.label.trim()) && field.value)
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ export function canonicalModelsToDataset(
|
|||
const occurrences = models.flatMap(model => model.findingOccurrences);
|
||||
const referenceByOccurrence = new Map(models.flatMap(model => model.referenceRuns ?? []).flatMap(run => run.results.map(result =>
|
||||
[result.occurrenceId, { runId: run.id, recognizerVersion: run.recognizerVersion, createdAt: run.createdAt, references: result.references }] as const)));
|
||||
if (!runs.length || (!occurrences.length && !(models.some(model => model.environmentObservations?.length)))) return null;
|
||||
if (!runs.length || (!occurrences.length && !models.some(model => model.environmentObservations?.length) && !tables.some(table => table.rows?.length))) return null;
|
||||
|
||||
const fileById = new Map(files.map(file => [file.id, file]));
|
||||
const runById = new Map(runs.map(run => [run.id, run]));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { CanonicalAssessmentModel } from '../../domain/canonical-model';
|
||||
import { DEFENDER_FOR_IOT_PROFILE_ID, defenderTableRole } from '../../domain/defender-for-iot';
|
||||
|
||||
export type DatahubScanReadiness = 'recognized' | 'generic' | 'not-viewable';
|
||||
|
||||
const genericToolIds = new Set(['generic', 'unknown', 'multiple']);
|
||||
|
||||
/** A stored ScanRun becomes viewable in Work once it owns canonical records. */
|
||||
/** A stored ScanRun becomes viewable once it owns canonical findings or a supported non-finding projection. */
|
||||
export function datahubScanReadiness(
|
||||
models: CanonicalAssessmentModel[],
|
||||
scanRunId: string,
|
||||
|
|
@ -12,7 +13,12 @@ export function datahubScanReadiness(
|
|||
): DatahubScanReadiness {
|
||||
const hasRun = models.some(model => model.scanRuns.some(run => run.id === scanRunId));
|
||||
const hasViewableRecords = models.some(model => model.findingOccurrences.some(occurrence => occurrence.scanRunId === scanRunId));
|
||||
if (!hasRun || !hasViewableRecords) return 'not-viewable';
|
||||
const hasDefenderSummary = toolId === DEFENDER_FOR_IOT_PROFILE_ID && models.some(model => {
|
||||
const fileIds = new Set(model.importFiles.filter(file => file.scanRunId === scanRunId).map(file => file.id));
|
||||
return model.sourceTables.some(table => fileIds.has(table.importFileId) && Boolean(table.rows?.length)
|
||||
&& defenderTableRole(table.rows?.[0]?.fields.map(field => field.label) ?? []) === 'summary');
|
||||
});
|
||||
if (!hasRun || (!hasViewableRecords && !hasDefenderSummary)) return 'not-viewable';
|
||||
return genericToolIds.has(toolId) ? 'generic' : 'recognized';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import type { JsonValue } from '../domain/canonical-model';
|
||||
import {
|
||||
buildDefenderVulnerabilityModel,
|
||||
defenderTableRole,
|
||||
type DefenderDevice,
|
||||
type DefenderObservation,
|
||||
type DefenderSourceRow,
|
||||
type DefenderSourceTable
|
||||
} from '../domain/defender-for-iot';
|
||||
import type { DecodedSourceDocument, ImportTable, SourceLocator } from '../domain/import-intermediate';
|
||||
import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction';
|
||||
|
||||
function headerFor(table: ImportTable): string[] {
|
||||
return table.headerCandidates[0]?.headers ?? [];
|
||||
}
|
||||
|
||||
function sourceRows(table: ImportTable): DefenderSourceRow[] {
|
||||
const header = table.headerCandidates[0];
|
||||
if (!header) return [];
|
||||
return table.rows
|
||||
.filter(row => row.index !== header.rowIndex && row.cells.some(cell => cell.decodedValue.trim()))
|
||||
.map(row => ({
|
||||
tableIndex: table.index,
|
||||
rowIndex: row.index,
|
||||
fields: Object.fromEntries(row.cells.map((cell, index) => [header.headers[index] || `Column ${index + 1}`, cell.rawValue]))
|
||||
}));
|
||||
}
|
||||
|
||||
function sourceTables(document: DecodedSourceDocument): DefenderSourceTable[] {
|
||||
return document.tables.map(table => ({ tableIndex: table.index, name: table.name, headers: headerFor(table), rows: sourceRows(table) }));
|
||||
}
|
||||
|
||||
function observationValue(observation: DefenderObservation): Record<string, JsonValue> {
|
||||
return {
|
||||
name: observation.name,
|
||||
ip: observation.ip,
|
||||
site: observation.site,
|
||||
sensor: observation.sensor,
|
||||
cve: observation.cve,
|
||||
severity: observation.severitySource,
|
||||
description: observation.description,
|
||||
additionalSourceFields: observation.additionalSourceFields,
|
||||
source: { tableIndex: observation.tableIndex, rowIndex: observation.rowIndex }
|
||||
};
|
||||
}
|
||||
|
||||
function field(key: string, label: string, originalValue: JsonValue, decodedValue: string, source: SourceLocator): SourceFieldCandidate {
|
||||
return { key, label, originalValue, decodedValue, source };
|
||||
}
|
||||
|
||||
function occurrenceCandidate(
|
||||
document: DecodedSourceDocument,
|
||||
occurrence: ReturnType<typeof buildDefenderVulnerabilityModel>['occurrences'][number],
|
||||
device: DefenderDevice
|
||||
): FindingCandidate {
|
||||
const first = occurrence.observations[0];
|
||||
const severityObservation = occurrence.observations.find(observation => observation.severity === occurrence.severity) ?? first;
|
||||
const source = { fileName: document.file.name, tableIndex: first.tableIndex, rowIndex: first.rowIndex };
|
||||
const observations = occurrence.observations.map(observationValue);
|
||||
return {
|
||||
version: 1,
|
||||
id: `defender-occurrence-${first.tableIndex}-${first.rowIndex}`,
|
||||
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('severity', 'Severity', severityObservation.severitySource, severityObservation.severitySource, source),
|
||||
field('description', 'Beschreibung', occurrence.description, occurrence.description, source),
|
||||
field('observation-count', 'Observation Count', observations.length, String(observations.length), source),
|
||||
field('source-observations', 'Source Observations', observations, JSON.stringify(observations), source)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export const defenderForIotSourceExtractor: SourceExtractor = {
|
||||
id: 'defender-for-iot-vulnerability-source-extractor',
|
||||
version: '1.0.0',
|
||||
detect(document) {
|
||||
const roles = document.tables.map(table => defenderTableRole(headerFor(table))).filter(Boolean);
|
||||
if (!roles.length) return { score: 0, evidence: [] };
|
||||
const both = roles.includes('summary') && roles.includes('detail');
|
||||
return {
|
||||
score: both ? 1 : 0.92,
|
||||
evidence: [both
|
||||
? 'A Defender for IoT asset summary and detailed vulnerability table were found.'
|
||||
: `A Defender for IoT ${roles[0]} table was found.`]
|
||||
};
|
||||
},
|
||||
extract(document) {
|
||||
const model = buildDefenderVulnerabilityModel(sourceTables(document));
|
||||
return {
|
||||
version: 1,
|
||||
candidates: model.occurrences.map(occurrence => occurrenceCandidate(
|
||||
document,
|
||||
occurrence,
|
||||
model.devices.find(device => device.id === occurrence.deviceId)!
|
||||
)),
|
||||
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
|
||||
}))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import type { DecodedObjectView, DecodedSourceDocument } from '../domain/import-
|
|||
import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction';
|
||||
import { stringifyCell } from '../shared/text';
|
||||
import { cisCatSourceExtractor } from './cis-cat-source-extractor';
|
||||
import { defenderForIotSourceExtractor } from './defender-for-iot-source-extractor';
|
||||
import { pingCastleSourceExtractor } from './pingcastle-source-extractor';
|
||||
import { maesterHtmlSourceExtractor } from './maester-html-source-extractor';
|
||||
import { maesterTableContextSourceExtractor } from './maester-table-context-source-extractor';
|
||||
|
|
@ -64,6 +65,7 @@ export const nativeObjectSourceExtractor: SourceExtractor = {
|
|||
|
||||
|
||||
export const sourceExtractors: SourceExtractor[] = [
|
||||
defenderForIotSourceExtractor,
|
||||
pingCastleSourceExtractor,
|
||||
cisCatSourceExtractor,
|
||||
purpleKnightHtmlSourceExtractor,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import './styles/functional-icons.css';
|
|||
import './styles/tile-contract.css';
|
||||
import './styles/filter-empty-state.css';
|
||||
import './styles/ai-overview.css';
|
||||
import './styles/defender-for-iot.css';
|
||||
|
||||
if (import.meta.env.DEV && new URLSearchParams(location.search).get('measureNavigation') === '1') {
|
||||
void import('./app/navigation-diagnostics').then(module => module.observeNavigationPerformance());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import type { ToolProfileDefinition } from '../../domain/tool-profile-definition';
|
||||
import { commonSeverityMappings, commonStatusMappings } from '../common-normalization';
|
||||
|
||||
export const defenderForIotDefinition: ToolProfileDefinition = {
|
||||
schemaVersion: 1,
|
||||
id: 'defender-for-iot-vulnerabilities',
|
||||
securityScoring: {
|
||||
version: '1.0.0',
|
||||
native: { kind: 'unavailable', reason: 'Affected-device counts describe vulnerability reach, not a native security score.' },
|
||||
targets: [], resultPopulation: 'findings-only'
|
||||
},
|
||||
priorityBasis: {
|
||||
version: '1.0.0',
|
||||
// The export states a native severity per CVE; that is the only ordering the
|
||||
// source declares directly.
|
||||
basis: { kind: 'severity', properties: ['vulnerability.severity'] }
|
||||
},
|
||||
timeslotComparison: {
|
||||
version: '1.0.0', kind: 'unavailable',
|
||||
reason: 'The Defender for IoT vulnerability export is a point-in-time CVE inventory without a stable scan scope or observation time for timeslot comparison.'
|
||||
},
|
||||
name: 'Microsoft Defender for IoT',
|
||||
version: '1.0.0',
|
||||
processing: {
|
||||
mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0',
|
||||
compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible',
|
||||
reimportRequired: false, affectedDimensions: []
|
||||
},
|
||||
sourceSignals: [
|
||||
{ scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' },
|
||||
{ scope: 'header', pattern: /^CVE$/i, contribution: 0.38, message: 'The detail table exposes CVE identifiers.' },
|
||||
{ scope: 'header', pattern: /^Sensor$/i, contribution: 0.14, message: 'The source records a reporting sensor.' },
|
||||
{ scope: 'header', pattern: /^Beschreibung$/i, contribution: 0.18, message: 'The detail table exposes the Defender CVE description field.' },
|
||||
{ scope: 'header', pattern: /^Critical$/i, contribution: 0.1, message: 'The asset summary exposes Critical counts.' }
|
||||
],
|
||||
tableRules: {
|
||||
preferredNames: [
|
||||
{ pattern: /asset|device|summary/i, contribution: 0.45, message: 'Likely asset summary table.' },
|
||||
{ pattern: /vulnerab|cve|detail/i, contribution: 0.55, message: 'Likely vulnerability detail table.' }
|
||||
],
|
||||
discouragedNames: []
|
||||
},
|
||||
fieldRules: [
|
||||
{ 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: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] }
|
||||
],
|
||||
normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
|
||||
presentation: {
|
||||
titleHeaders: [/^CVE$/i],
|
||||
severityHeaders: [/^Severity$/i],
|
||||
sortHeaders: [/^Severity$/i],
|
||||
sortDirection: 'desc',
|
||||
defaultStatusFilter: 'deviation',
|
||||
statusFilters: [
|
||||
{ value: 'all', label: 'All Vulnerabilities', patterns: [] },
|
||||
{ value: 'deviation', label: 'Vulnerabilities', patterns: [/^critical$/i, /^high$/i, /^medium$/i, /^low$/i] }
|
||||
],
|
||||
resultSectionHeaders: [/^Source Observations$/i]
|
||||
},
|
||||
comparisonKeys: [['rule-id', 'asset']]
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { createImportProfile } from '../profile-factory';
|
||||
import { defenderForIotDefinition } from './defender-for-iot-definition';
|
||||
|
||||
export const defenderForIotProfile = createImportProfile(defenderForIotDefinition);
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
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 { genericProfile } from './generic/generic-profile';
|
||||
import { maesterProfile } from './maester/maester-profile';
|
||||
import { pingCastleProfile } from './pingcastle/pingcastle-profile';
|
||||
import { purpleKnightProfile } from './purple-knight/purple-knight-profile';
|
||||
|
||||
export const importProfiles: ImportProfile[] = [
|
||||
defenderForIotProfile,
|
||||
purpleKnightProfile,
|
||||
pingCastleProfile,
|
||||
cisCatProfile,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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 { genericDefinition } from './generic/generic-definition';
|
||||
import { maesterDefinition } from './maester/maester-definition';
|
||||
import { pingCastleDefinition } from './pingcastle/pingcastle-definition';
|
||||
import { purpleKnightDefinition } from './purple-knight/purple-knight-definition';
|
||||
|
||||
export const toolProfileDefinitions: ToolProfileDefinition[] = [
|
||||
defenderForIotDefinition,
|
||||
purpleKnightDefinition,
|
||||
pingCastleDefinition,
|
||||
cisCatDefinition,
|
||||
|
|
|
|||
|
|
@ -78,8 +78,3 @@
|
|||
.ai-overview-priorities dl { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* Client Info view tabs (shared with the scanner-specific Client Info views). */
|
||||
.client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||
.client-info-tabs button:hover { background: var(--tile-surface-hover); }
|
||||
.client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||
.client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
.defender-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--segment-gap); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
|
||||
.defender-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-context strong { color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||
.defender-context .work-timeslot-select { margin-left: auto; }
|
||||
.defender-metrics { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||
.defender-metrics article { display: grid; align-content: space-between; gap: var(--space-3); min-height: 128px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||
.defender-metrics span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
|
||||
.defender-metrics strong { color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-regular); font-variant-numeric: tabular-nums; }
|
||||
.defender-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.defender-tabs button, .client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||
.defender-tabs button:hover, .client-info-tabs button:hover { background: var(--tile-surface-hover); }
|
||||
.defender-tabs button:focus-visible, .client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||
.defender-tabs button[aria-pressed="true"], .client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
|
||||
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||
.defender-view > header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.defender-view > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); }
|
||||
.defender-view > header span { color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-table-wrap { min-width: 0; overflow-x: auto; background: var(--tile-surface); }
|
||||
.defender-table { width: 100%; border-collapse: collapse; table-layout: auto; color: var(--text); background: var(--tile-surface); font-size: var(--type-table-body-size); }
|
||||
.defender-table th { padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); text-align: left; text-transform: uppercase; vertical-align: bottom; }
|
||||
.defender-table td { max-width: 440px; padding: var(--space-3); border-bottom: var(--border-hair) solid var(--line); line-height: 1.45; overflow-wrap: normal; word-break: normal; vertical-align: top; }
|
||||
.defender-table td > button { padding: 0; border: 0; color: var(--heading); background: transparent; font: inherit; font-weight: var(--fw-semibold); text-align: left; cursor: pointer; }
|
||||
.defender-table td > button:hover { background: var(--tile-surface-hover); }
|
||||
.defender-table td > button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.defender-table td > button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: 2px; }
|
||||
.defender-table small { display: block; margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-severity { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||
.defender-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
|
||||
.defender-conflict, .defender-warning { color: var(--warning); }
|
||||
.defender-unavailable, .defender-empty { color: var(--muted); }
|
||||
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||
.defender-drilldown h3, .defender-drilldown h4 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
|
||||
.defender-drilldown h3 { font-size: var(--type-detail-title-size); }
|
||||
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
||||
.defender-drilldown p { margin: 0; font-size: var(--type-body-size); line-height: 1.55; }
|
||||
.defender-drilldown details { border-top: var(--border-hair) solid var(--line); padding-top: var(--space-3); }
|
||||
.defender-drilldown summary { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||
.defender-drilldown details > .defender-table, .defender-drilldown details > .defender-drilldown { margin-top: var(--space-3); }
|
||||
.defender-aggregate-summary, .defender-severity-strip { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); }
|
||||
.defender-aggregate-summary > span, .defender-severity-strip article { display: grid; gap: var(--space-1); min-width: var(--tile-compact-width); padding: var(--space-3); background: var(--surface-inset); }
|
||||
.defender-aggregate-summary strong { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); }
|
||||
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); }
|
||||
.defender-severity-strip article { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
|
||||
.defender-severity-strip strong { color: var(--heading); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 760px) { .defender-view > header { display: grid; } .defender-context .work-timeslot-select { margin-left: 0; } }
|
||||
|
|
@ -41,6 +41,13 @@ describe('processing compatibility contracts', () => {
|
|||
expect(processingScopeIssues([old, contextless, current])).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the recorded profile when a caller supplies a different detected tool id', () => {
|
||||
const defender = file('defender', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0');
|
||||
expect(processingReadiness('microsoft-defender-for-iot', defender.processedWith)).toBe('current');
|
||||
expect(resolvedCompatibilityKey('microsoft-defender-for-iot', defender.processedWith))
|
||||
.toBe('defender-for-iot-vulnerabilities-v1');
|
||||
});
|
||||
|
||||
it('blocks semantically incompatible versions in one scope', () => {
|
||||
const legacy = file('legacy', 'pingcastle', '1.0.0', '1.0.0');
|
||||
const current = file('current', 'pingcastle', '1.4.0', '1.2.0');
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ describe('Client Info AI Overview', () => {
|
|||
|
||||
it('adds the AI tab beside the Client Info overview', () => {
|
||||
const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
||||
expect(dashboard).toContain("['overview', 'Overview'] as const,");
|
||||
expect(dashboard).toContain("[['overview', 'Overview'] as const]");
|
||||
expect(dashboard).toContain('{clientInfoTabs}');
|
||||
expect(dashboard).toContain("...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])");
|
||||
expect(dashboard).toContain("clientInfoView === 'ai'");
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ function file(scanRunId: string, profileId: string, profileVersion: string, comp
|
|||
}
|
||||
|
||||
const currentPurpleKnight = (scanRunId: string) => file(scanRunId, 'purple-knight', '1.4.0', 'purple-knight-canonical-v1.1');
|
||||
function withDetectedTool(fileRecord: ImportFileRecord, id: string): ImportFileRecord {
|
||||
return { ...fileRecord, detectedTool: { id, name: 'Microsoft Defender for IoT' } };
|
||||
}
|
||||
|
||||
describe('Datahub client processing readiness', () => {
|
||||
it('keeps a complete 22-file PurpleKnight import ready after serialization and repeated import', async () => {
|
||||
|
|
@ -120,6 +123,16 @@ describe('Datahub client processing readiness', () => {
|
|||
)).toEqual({ state: 'ready', label: 'Processing ready across all client timeslots.' });
|
||||
});
|
||||
|
||||
it('assesses the stored profile contract independently from the detected tool identity', () => {
|
||||
const defender = withDetectedTool(
|
||||
file('run-defender', 'defender-for-iot-vulnerabilities', '1.0.0', 'defender-for-iot-vulnerabilities-v1'),
|
||||
'microsoft-defender-for-iot'
|
||||
);
|
||||
expect(summarizeScanProcessingReadiness([defender])).toEqual({
|
||||
state: 'ready', statuses: ['current'], explanation: 'The scan uses a current or compatible ASSESSOR processing profile.'
|
||||
});
|
||||
});
|
||||
|
||||
it('marks reimport recommendations as a yellow update notice', () => {
|
||||
expect(summarizeClientProcessingReadiness(
|
||||
[file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')],
|
||||
|
|
|
|||
|
|
@ -26,4 +26,16 @@ describe('Datahub scan readiness', () => {
|
|||
expect(isDatahubScanViewable('checking')).toBe(false);
|
||||
expect(isDatahubScanViewable('unavailable')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a Defender asset summary viewable without fabricating findings', () => {
|
||||
const value = model(['defender-summary'], []);
|
||||
value.importFiles = [{ id: 'file-1', scanRunId: 'defender-summary' }] as CanonicalAssessmentModel['importFiles'];
|
||||
value.sourceTables = [{
|
||||
id: 'table-1', importFileId: 'file-1', name: 'Assets', index: 0, rowCount: 1, columnCount: 9, attributes: {},
|
||||
rows: [{ index: 1, fields: ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'].map((label, columnIndex) => ({ key: `column-${columnIndex}`, label, value: columnIndex > 3 ? 0 : 'synthetic' })) }]
|
||||
}];
|
||||
|
||||
expect(datahubScanReadiness([value], 'defender-summary', 'defender-for-iot-vulnerabilities')).toBe('recognized');
|
||||
expect(value.findingOccurrences).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||
import { rowsToCsv } from '../../src/exports/csv-export';
|
||||
import { defenderModelForDataset } from '../../src/features/defender-for-iot/defender-dataset-model';
|
||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||
|
||||
function dataset(): ImportedDataset {
|
||||
const summary = [
|
||||
['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'],
|
||||
['device-a', '', 'Site A', 'sensor-a', '3', '1', '2', '0', '0']
|
||||
];
|
||||
const observations = [
|
||||
{ name: 'device-a', ip: '', site: 'Site A', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'Critical', description: 'Synthetic description', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 1 } },
|
||||
{ name: 'device-a', ip: '', site: 'Site A', sensor: 'sensor-b', cve: 'CVE-2026-1001', severity: 'Critical', description: 'Synthetic description', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 2 } }
|
||||
];
|
||||
const detail = [
|
||||
['CVE', 'Device Identity', 'Name', 'IP', 'Site', 'Reporting Sensors', 'Severity', 'Beschreibung', 'Observation Count', 'Source Observations'],
|
||||
['CVE-2026-1001', 'name:device-a|site:site a', 'device-a', '', 'Site A', 'sensor-a, sensor-b', 'Critical', 'Synthetic description', '2', JSON.stringify(observations)]
|
||||
];
|
||||
return {
|
||||
fileName: 'Synthetic project', fileSize: 1, lastModified: 0, format: 'Project · Microsoft Defender for IoT', rawText: '',
|
||||
profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', scanTool: 'Microsoft Defender for IoT',
|
||||
sheets: [
|
||||
makeSheet('Vulnerabilities', detail, rowsToCsv(detail), 'synthetic.xlsx', { sourceTableIndex: 1, sourceSheet: 'Vulnerabilities', toolId: 'defender-for-iot-vulnerabilities' }),
|
||||
makeSheet('Assets', summary, rowsToCsv(summary), 'synthetic.xlsx', { sourceTableIndex: 0, sourceSheet: 'Assets', toolId: 'defender-for-iot-vulnerabilities' })
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
describe('Defender vulnerability dashboard projection', () => {
|
||||
it('reconstructs raw observations without double-counting the actionable occurrence', () => {
|
||||
const model = defenderModelForDataset(dataset());
|
||||
|
||||
expect(model.occurrences).toHaveLength(1);
|
||||
expect(model.observations).toHaveLength(2);
|
||||
expect(model.devices[0].severity.critical).toMatchObject({ detail: 1, summary: 1, state: 'complete' });
|
||||
expect(model.devices[0].severity.high).toMatchObject({ detail: 0, summary: 2, aggregateOnly: 2, state: 'aggregate-only' });
|
||||
});
|
||||
|
||||
it('keeps the required drill-downs and avoids clipping or ellipsis rules', () => {
|
||||
const component = readFileSync(new URL('../../src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx', import.meta.url), 'utf8');
|
||||
const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
||||
const styles = readFileSync(new URL('../../src/styles/defender-for-iot.css', import.meta.url), 'utf8');
|
||||
|
||||
expect(component).toContain('CVE view');
|
||||
expect(component).toContain('Device view');
|
||||
expect(component).toContain('Site dashboard');
|
||||
expect(component).toContain('Source row');
|
||||
expect(component).toContain('aggregate-only');
|
||||
expect(dashboard).toContain("[['overview', 'Overview'] as const]");
|
||||
expect(dashboard).toContain("[['defender', 'Microsoft Defender for IoT'] as const]");
|
||||
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />');
|
||||
expect(styles).toContain('padding: var(--panel-inset)');
|
||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--marking-color)');
|
||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
|
||||
expect(styles).not.toContain('text-overflow: ellipsis');
|
||||
expect(styles).not.toContain('line-clamp');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { FindingRecord } from '../../src/domain/finding';
|
||||
import { recordTitleFor } from '../../src/features/dataset-viewer/record-title';
|
||||
|
||||
function record(fields: Array<[string, string]>, title = 'fallback'): FindingRecord {
|
||||
return {
|
||||
id: 'synthetic-record', index: 0, title,
|
||||
fields: fields.map(([label, value], index) => ({ label, value, index })),
|
||||
filled: [], meta: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('mixed Work record titles', () => {
|
||||
it('uses the Defender CVE instead of the device name selected by the generic aggregate profile', () => {
|
||||
const defender = record([
|
||||
['Source: Scan', 'Microsoft Defender for IoT · 2026-09-10T08:00:00.000Z'],
|
||||
['Name', '10.0.0.42'],
|
||||
['CVE', 'CVE-2026-4242']
|
||||
]);
|
||||
|
||||
expect(recordTitleFor(defender, 'Name', 'generic')).toBe('CVE-2026-4242');
|
||||
});
|
||||
|
||||
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,178 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { buildDefenderVulnerabilityModel, type DefenderSourceRow, type DefenderSourceTable } from '../../src/domain/defender-for-iot';
|
||||
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 { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness';
|
||||
|
||||
const summaryHeaders = ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'];
|
||||
const detailHeaders = ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung'];
|
||||
|
||||
function table(tableIndex: number, headers: string[], rows: Array<Record<string, unknown>>): DefenderSourceTable {
|
||||
return {
|
||||
tableIndex,
|
||||
name: headers.includes('CVE') ? 'Vulnerability detail' : 'Asset summary',
|
||||
headers,
|
||||
rows: rows.map((fields, index): DefenderSourceRow => ({ tableIndex, rowIndex: index + 1, fields: fields as DefenderSourceRow['fields'] }))
|
||||
};
|
||||
}
|
||||
|
||||
function summary(name: string, ip: string, site: string, counts: [number, number, number, number], sensor = 'sensor-a') {
|
||||
return { Name: name, IP: ip, Site: site, Sensor: sensor, TotalCVEs: counts.reduce((sum, value) => sum + value, 0), Critical: counts[0], High: counts[1], Medium: counts[2], Low: counts[3] };
|
||||
}
|
||||
|
||||
function detail(name: string, ip: string, site: string, sensor: string, cve: string, severity: string, description = 'Synthetic description', nullValue: unknown = null) {
|
||||
return { Name: name, IP: ip, Site: site, Sensor: sensor, CVE: cve, Severity: severity, NULL: nullValue, Beschreibung: description };
|
||||
}
|
||||
|
||||
describe('Microsoft Defender for IoT vulnerability model', () => {
|
||||
it('models CVEs, devices, occurrences, observations, and aggregate-only counts independently', () => {
|
||||
const model = buildDefenderVulnerabilityModel([
|
||||
table(0, summaryHeaders, [
|
||||
summary('00:11:22:33:44:55', '10.0.0.1', 'North', [2, 3, 0, 1]),
|
||||
summary('device-b', '10.0.0.2', 'North', [1, 0, 0, 0])
|
||||
]),
|
||||
table(1, detailHeaders, [
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical'),
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-b', 'CVE-2026-0001', 'Critical'),
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0002', 'Critical'),
|
||||
detail('device-b', '10.0.0.2', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical')
|
||||
])
|
||||
]);
|
||||
|
||||
expect(model.cves).toHaveLength(2);
|
||||
expect(model.devices).toHaveLength(2);
|
||||
expect(model.occurrences).toHaveLength(3);
|
||||
expect(model.observations).toHaveLength(4);
|
||||
expect(model.cves.find(item => item.id === 'CVE-2026-0001')).toMatchObject({ deviceIds: expect.arrayContaining([expect.any(String), expect.any(String)]), observationCount: 3 });
|
||||
expect(model.occurrences.find(item => item.cve === 'CVE-2026-0001' && item.observations.length === 2)?.sensors).toEqual(['sensor-a', 'sensor-b']);
|
||||
expect(model.devices.find(item => item.displayName === '00:11:22:33:44:55')?.severity.high).toMatchObject({ detail: 0, summary: 3, aggregateOnly: 3, state: 'aggregate-only' });
|
||||
expect(model.aggregateOnlyCount).toBe(4);
|
||||
expect(model.sites[0]).toMatchObject({ assetCount: 2, uniqueCveCount: 2, affectedDeviceCount: 2, rawObservationCount: 4 });
|
||||
});
|
||||
|
||||
it('uses MAC, IP, and site-scoped name identities without merging a reused IP across sites', () => {
|
||||
const model = buildDefenderVulnerabilityModel([table(0, detailHeaders, [
|
||||
detail('AA-BB-CC-DD-EE-FF', '10.0.0.1', 'A', 's1', 'CVE-2026-0101', 'High'),
|
||||
detail('device-ip', '10.0.0.2', 'A', 's1', 'CVE-2026-0102', 'High'),
|
||||
detail('device-name', '', 'A', 's1', 'CVE-2026-0103', 'Medium'),
|
||||
detail('device-reused-a', '10.0.0.9', 'A', 's1', 'CVE-2026-0104', 'Low'),
|
||||
detail('device-reused-b', '10.0.0.9', 'B', 's2', 'CVE-2026-0104', 'Low'),
|
||||
detail('', '', '', '', 'CVE-2026-0105', 'Critical')
|
||||
])]);
|
||||
|
||||
expect(model.devices.map(item => item.identityMethod)).toEqual(expect.arrayContaining(['mac', 'ip', 'name', 'unresolved']));
|
||||
expect(model.devices.filter(item => item.ip === '10.0.0.9')).toHaveLength(2);
|
||||
expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([
|
||||
'defender-device-identity-site-scoped', 'defender-device-identity-unresolved'
|
||||
]));
|
||||
});
|
||||
|
||||
it('keeps duplicate rows as evidence and flags conflicts, unexpected NULL data, and reconciliation failures', () => {
|
||||
const duplicate = detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0201', 'Critical', 'Description A');
|
||||
const model = buildDefenderVulnerabilityModel([
|
||||
table(0, summaryHeaders, [
|
||||
{ ...summary('device-a', '', 'A', [3, 0, 0, 0]), TotalCVEs: 99 },
|
||||
summary('device-a', '', 'A', [2, 0, 0, 0])
|
||||
]),
|
||||
table(1, detailHeaders, [
|
||||
duplicate,
|
||||
{ ...duplicate },
|
||||
detail('device-a', '', 'A', 'sensor-b', 'CVE-2026-0201', 'High', 'Description B', 'preserve-me'),
|
||||
detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0202', 'Critical')
|
||||
])
|
||||
]);
|
||||
|
||||
expect(model.occurrences).toHaveLength(2);
|
||||
expect(model.observations).toHaveLength(4);
|
||||
expect(model.observations.some(item => item.additionalSourceFields.NULL === 'preserve-me')).toBe(true);
|
||||
expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([
|
||||
'defender-summary-total-mismatch',
|
||||
'defender-summary-conflict',
|
||||
'defender-duplicate-source-observation',
|
||||
'defender-null-column-populated',
|
||||
'defender-cve-severity-conflict',
|
||||
'defender-cve-description-conflict',
|
||||
'defender-summary-detail-mismatch'
|
||||
]));
|
||||
});
|
||||
|
||||
it('distinguishes zero, unavailable detail, and single-sheet variants', () => {
|
||||
const summaryOnly = buildDefenderVulnerabilityModel([table(0, summaryHeaders, [summary('device-a', '', 'A', [0, 2, 0, 0])])]);
|
||||
const detailOnly = buildDefenderVulnerabilityModel([table(0, detailHeaders, [detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0301', 'Medium')])]);
|
||||
|
||||
expect(summaryOnly.available).toBe(true);
|
||||
expect(summaryOnly.occurrences).toHaveLength(0);
|
||||
expect(summaryOnly.devices[0].severity.critical.state).toBe('zero');
|
||||
expect(summaryOnly.devices[0].severity.high.state).toBe('aggregate-only');
|
||||
expect(detailOnly.devices[0].severity.medium.state).toBe('detail-only');
|
||||
expect(detailOnly.devices[0].severity.low.state).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('selects the versioned profile and emits one candidate per device and CVE', 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])
|
||||
], { 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')
|
||||
], { 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 });
|
||||
|
||||
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' });
|
||||
expect(imported.extraction?.candidates).toHaveLength(1);
|
||||
expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(2);
|
||||
expect(imported.mapping?.candidates).toHaveLength(1);
|
||||
expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([1, 2]);
|
||||
|
||||
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
|
||||
sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
||||
}), importProfiles);
|
||||
const canonical = createCanonicalImportBatch('defender-synthetic', [entry], '2026-09-10T06:00:00.000Z').model;
|
||||
expect(canonical.importFiles[0]).toMatchObject({
|
||||
detectedTool: { id: 'defender-for-iot-vulnerabilities' },
|
||||
processedWith: {
|
||||
profileId: 'defender-for-iot-vulnerabilities',
|
||||
profileVersion: '1.0.0',
|
||||
compatibilityKey: 'defender-for-iot-vulnerabilities-v1'
|
||||
}
|
||||
});
|
||||
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');
|
||||
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);
|
||||
});
|
||||
|
||||
it('keeps a summary-only import viewable without creating finding definitions or occurrences', async () => {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
|
||||
summary('synthetic-device', '', 'Synthetic site', [0, 2, 0, 0])
|
||||
], { header: summaryHeaders }), 'Assets');
|
||||
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
||||
const imported = await importFile(new File([bytes], 'defender-summary-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles });
|
||||
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
|
||||
sha256: 'e'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
||||
}), importProfiles);
|
||||
const canonical = createCanonicalImportBatch('defender-summary-synthetic', [entry], '2026-09-10T06:00:00.000Z').model;
|
||||
const projected = canonicalModelToDataset(canonical);
|
||||
|
||||
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
|
||||
expect(canonical.findingDefinitions).toHaveLength(0);
|
||||
expect(canonical.findingOccurrences).toHaveLength(0);
|
||||
expect(projected?.profileId).toBe('defender-for-iot-vulnerabilities');
|
||||
expect(projected?.sheets.some(sheet => sheet.analysis.headers.includes('TotalCVEs'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue