Neuer Stand Assessor #1
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Status: lebendes Projektdokument
|
||||
|
||||
Defender-device-scoped risk acceptance 2026-09-21 (Phases 7 and 9-11 extension with Phase 13 validation, explicit user request): Existing whole-finding acceptance remains unchanged. Work can record one CVE for one exact Defender device; Defender Device view can record one immutable snapshot of every detailed actionable CVE in the selected active scan-run scope. The shared platform-neutral projection uses the existing Defender identity model, rejects unresolved or ambiguous identities, retains canonical definition/CVE and exact occurrence provenance, and carries coverage forward only for the same project, exact device key and captured definition. Independent whole-finding, device-finding and device-snapshot decisions overlap without revoking each other. Migration 028, repository/API parity, project export/load, optimistic revision checks and the Accepted Risks register are implemented. The canonical PostgreSQL datastore was backed up and verified before migration; 028 is recorded and a synthetic PostgreSQL accept/conflict/revoke/export/cascade test passed with cleanup. Verification: 287 files and 1,567 tests passed with six files and 16 opt-in tests skipped; client/server TypeScript checks, local-only and design-language checks, both production builds and the dedicated canonical PostgreSQL integration passed. Phase 14 authentication, authorization, signing, evidence verification and retention remain explicitly unclaimed.
|
||||
|
||||
Wiederholbarer ISO-Mappingvergleich 2026-09-18 (Phase 13D, ausdruecklicher Nutzerauftrag): Ein read-only Evaluationsskript vergleicht abgeschlossene PostgreSQL-Mappinglaeufe fuer PingCastle und Purple Knight mit einer versionierten manuellen Baseline und erzeugt eine reviewfaehige Excel-Datei mit Summary, Findingvergleich, Beziehungsdetails und Methodik. Ohne Parameter erkennt es die aktuelle Retrievalversion aus dem Produktivcode und waehlt die neueste vorherige Version mit vollstaendiger Scannerabdeckung; unvollstaendige neue Laeufe stoppen explizit. Die Baseline enthaelt ausschliesslich oeffentliche Rule-IDs und manuell bewertete Controlrollen, erlaubt null Primaercontrol und ist bewusst Evaluationsmaterial statt CI-Sollvertrag oder Produktmapping. Dauerhafte Tests verwenden nur synthetische Kontrollkennungen und pruefen Validierung, Retrieval-/Auswahl-/Rollen-/Extra-Trennung sowie Aenderungsklassifikation. Der erzeugte Smoke-Workbook bestand Import, Strukturscan, Formelpruefung und visuelle Kontrolle aller vier Blaetter. Beide Produktionsbuilds erfolgreich; der fokussierte neue Test besteht 3/3. Der Gesamtlauf erreicht 1.331 bestandene Tests und zehn opt-in-Skips, wird aber reproduzierbar durch den fachfremden vorhandenen 5-Sekunden-Timeout in `sheet-selection-cost.test.tsx` beendet; derselbe Test besteht isoliert in 0,9 Sekunden. Keine PostgreSQL-/Qdrant-Mutation, keine Mappingannahme, kein Manifestbump und kein Reimport.
|
||||
|
||||
ISO-Full-Context-Familienfusion 2026-09-18 (Phase 13D, ausdruecklicher Nutzerauftrag): Die bestehende Finding-only Querygrenze bleibt unveraendert. Die mit vollstaendigem Findingtext ausgefuehrten `control`-, `purpose`- und `guidance`-Suchen werden jetzt zunaechst zu genau einer Control-Rangliste fusioniert; diese Full-Context-Familie stimmt anschliessend neben Problem- und optionaler Remediationrangliste in der finalen Control-RRF ab. Damit koennen Abschnittstreffer wieder Controls in die Zwolf-Control-Shortlist einbringen, ohne drei korrelierte Schlussstimmen zu erhalten. Retrievalversion `hybrid-finding-query-section-family-rrf-1.6.0` erzeugt append-only neue Mapping-IDs; Queryschema 4, Prompt 1.5.0, vollstaendige Kandidatenbewertung, deterministische Auswahl, Human Review und fehlender produktiver Reranker bleiben unveraendert. Synthetische Mechanikregressionen pruefen Eintritt eines Abschnittstreffers und die einzelne Familienstimme, aber keine versionsabhaengigen ISO-Sollzuordnungen. 1.329 Tests bestanden, zehn opt-in Integrationen uebersprungen; beide Typechecks, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Die reale Recall-/Praezisionswirkung bleibt durch einen neuen manuellen PingCastle-/Purple-Knight-Lauf zu bewerten. Keine PostgreSQL-/Qdrant-Mutation, keine Scannerverarbeitung, kein Manifestbump und kein Reimport.
|
||||
|
|
@ -2026,6 +2028,7 @@ Festlegung 2026-09-06 (Phase 13B/13C, ausdruecklicher Nutzerwunsch): Vor Rueckfr
|
|||
Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene Docker-Compose-Bestand `assessor-2_0` mit der Datenbank `assessor`, dem Container `assessor-2_0-postgres-1` und dem persistenten Volume `assessor-2_0_assessor-postgres-data` ist der einzige kanonische lokale ASSESSOR-Datenbestand fuer diesen Workspace. Lokale Starts verwenden ausschliesslich `127.0.0.1:5432`. Die Compose-Konfiguration bindet dieses Volume deshalb namentlich als `external`; bei fehlendem Bestand oder Portkonflikt muss der Start fehlschlagen, statt eine weitere ASSESSOR-Datenbank oder einen Ersatzport anzulegen. Diese ausdrueckliche Nutzerfestlegung ersetzt die bisherige implizite, verzeichnisabhaengige Compose-Projektidentitaet. Der interne Browser-/Backend-/PostgreSQL-Zielaufbau bleibt unveraendert.
|
||||
|
||||
| Datum | Entscheidung | Begründung |
|
||||
| 2026-09-21 | Defender scoped risk acceptance keeps the one-finding-per-CVE projection. A device-finding decision binds one canonical CVE definition to one exact stable device key; a device-snapshot decision is a server-derived immutable set of all detailed actionable CVEs in the selected active scan-run scope. Coverage is the union of independent valid decisions and carries forward only inside the same project for the same exact device key and captured definition. | This preserves existing whole-finding behavior and source provenance while allowing the two requested device-level workflows. Server-derived membership prevents a client from omitting current CVEs; immutable keys and fail-closed unresolved/ambiguous identities prevent accidental cross-device or cross-import carry-forward. |
|
||||
| 2026-09-15 | Nicht-personenbezogene Umgebungsaggregate (Konten-, Inventar-, Passwortrichtlinien- und Konfigurationszaehlungen bzw. -booleans) ueberschreiten die Result-DB-Offenlegungsgrenze und den AI-Datenvertrag ausschliesslich ueber exakte Property-Allowlist-Regeln; identifizierende Namen und alle Zeitstempel bleiben ausgeschlossen. Die Werte werden aus bereits extrahierten Rohbeobachtungen abgeleitet und unter stabilen Property-Namen statt fragiler `tabelle.N`-Schluessel emittiert. | Client Info zeigte diese Aggregate bereits aus dem Roh-Snapshot, waehrend Result DB und AI sie fail-closed verwarfen. Stabile Ableitungen erlauben eine explizite, pruefbare Offenlegung ohne die fail-closed Grenze zu einer generischen Freigabe zu oeffnen; der Nutzer hat den Umfang (Konten inkl. Aufschluesselung, Inventar, Richtlinie, Konfiguration) und den Ausschluss von Namen/Zeitstempeln ausdruecklich bestimmt. |
|
||||
| 2026-09-14 | AI Overview verwendet ausschliesslich das per Backend konfigurierte, unternehmensverwaltete LiteLLM ueber HTTPS; ein lokales Modell oder Browserzugriff auf Gateway/Key ist nicht vorgesehen. Der vollstaendige aktive Timeslot wird ueber den bestehenden Privacy-Safe-JSON-Vertrag uebertragen, grosse Eingaben werden ohne Record-Trunkierung hierarchisch zusammengefuehrt, und nur validierte Endergebnisse werden je Timeslot persistiert. | Der Nutzer hat Work-LiteLLM samt URL, Modellalias und API-Key bereit. Die bestehende Exportgrenze verhindert einen zweiten, weiter gefassten AI-Datenvertrag; serverseitige Secrets, explizite Ausloesung, lokale Validierung und append-only Historie halten Netz-, Kosten-, Datenschutz- und Auditgrenzen nachvollziehbar. |
|
||||
| 2026-09-14 | Die synthetische Mock-CVE-Datei wiederholt `Site` und `Sensor` je Zeile als ausdruecklich simulierte, ueber `Device ID` konsistent abgeleitete Fixture-Felder. Der Parser behandelt sie wie vorhandene optionale CVE-Kontextfelder; der Mehrdatei-Inventarjoin bleibt zusaetzlich bestehen. | Die CVE-Datei muss den Mockup auch als Einzelimport vollstaendig speisen. Die Kennzeichnung verhindert, dass die Zusatzspalten als behaupteter nativer Defender-Exportvertrag missverstanden werden. |
|
||||
|
|
@ -2435,6 +2438,7 @@ Offen 2026-09-06 (Phase 13B): Die automatische Kontextableitung unterstuetzt der
|
|||
Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projektnamen oder spontane Portausweichungen koennen mehrere auseinanderlaufende ASSESSOR-Datenbanken erzeugen und die App unbemerkt mit einem anderen Datenstand verbinden. Gegenmassnahme: `assessor-2_0` und `assessor-2_0_assessor-postgres-data` sind in Compose und `AGENTS.md` kanonisch gebunden; das Volume ist extern, Port `5432` verbindlich, alternative ASSESSOR-Datenbanken und Fallbackports sind untersagt. Vor Persistenzmigrationen oder destruktiven Eingriffen ist ein verifiziertes Backup erforderlich; fremde Docker-Bestaende bleiben unberuehrt.
|
||||
|
||||
| Risiko | Gegenmaßnahme |
|
||||
| A Defender device identity can change, collide, become ambiguous, or lack detailed CVE evidence; a mutable bulk scope could then silently cover the wrong device or newly discovered vulnerabilities. | Rebuild targets only through the shared Defender identity model, reject unresolved/ambiguous identities, store immutable device metadata and per-member occurrence/run/file/table/row provenance, derive snapshots server-side, and require a new decision for changed keys or new CVE definitions. Aggregate-only counts remain non-actionable. |
|
||||
| Eine kuenftige Erweiterung der Umgebungsaggregate koennte identifizierende Daten (Domaenen-/Forest-/Hostnamen, Zeitstempel) versehentlich in Result DB oder AI Overview aufnehmen. | Offenlegung bleibt fail-closed mit exakter Tool-/Property-Allowlist und engen Wertvalidatoren; die Ableitung erzeugt ausschliesslich Zaehl-/Booleanwerte unter neutralem `active-directory-environment`-Subject ohne Quellidentitaet. Regressionstests pruefen gleichzeitig die Aufnahme der Aggregate und den fortgesetzten Ausschluss von `domain.fqdn`; neue offenzulegende Properties erfordern eine explizite Regel plus `SAFE_ENVIRONMENT_PROPERTIES`-Eintrag. |
|
||||
| Eine spaetere Kartenrefaktorierung kann wieder alle Client-Info-Karten standardmaessig oeffnen und damit die vereinbarte kompakte Startansicht verlieren. | Den Startzustand an jeder konkreten Karteninstanz explizit setzen und statisch pruefen: neun `initiallyExpanded={false}` sowie genau ein nicht minimierter `Assessment Status`; die gemeinsame Komponente bleibt fuer gezielte Wiederverwendung konfigurierbar. |
|
||||
| Eine minimierte Client-Info-Karte kann durch intrinsische Flexbreite schmaler als ihre Spalte werden; werkzeugspezifische Provenienz kann ausserdem erneut in einen fachlichen Verzeichnisueberblick geraten. | Collapsed-Karten explizit auf `width: 100%` setzen und diese Geometrie regressionspruefen. `directoryFacts` auf fachliche Directory-Felder begrenzen; Scanner-Engine-Versionen bleiben in der Quellprovenienz statt in `Directory Overview`. |
|
||||
|
|
@ -2751,6 +2755,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-09-21 | Defender device-scoped Accepted Risk implemented across domain, Local/PostgreSQL repositories, Same-Origin API, application loading/export, Work scope selector, Defender Device bulk snapshot dialog, coverage/overlap display and the Accepted Risks register. Migration 028 was applied only after verified custom-format backup `backups/assessor-pre-028-20260921.backup` (SHA-256 `0828CEEA875A00DF4D9613737445C4837D208FBD6D96EEFA988F6745FDAE4946`) of canonical `assessor-2_0`; the migration ledger records `scoped-risk-acceptances`. Synthetic domain/reimport/UI/repository/API/schema checks pass 41/41, and the dedicated canonical PostgreSQL persistence/conflict/revoke/export/cascade integration passes 1/1 with cleanup. Final verification passes 287 files and 1,567 tests with six files and 16 opt-in tests skipped, client/server TypeScript checks, local-only and design-language checks, and both production builds; the client build retains the existing over-500-kB chunk warning. Existing findings and reviews were not rewritten; no reimport is required for reconstructable Defender evidence, and Phase 14 governance remains open. |
|
||||
| 2026-09-18 | Phase 13D, wiederholbarer manueller ISO-Mappingvergleich: `npm run rag:compare-mappings` liest die vollstaendigen PingCastle-/Purple-Knight-Laeufe read-only aus dem kanonischen lokalen PostgreSQL, erkennt standardmaessig aktuelle und vorherige Retrievalversion und erzeugt eine formatierte Excel-Pruefdatei mit Summary, Findingvergleich, Beziehungsdetails und Methodik. Die versionierte Baseline speichert nur oeffentliche Rule-IDs und manuelle Primaer-/Supportingrollen einschliesslich null Primaer; sie ist kein Produktmapping und kein CI-Sollvertrag. Synthetische Tests pruefen nur die generische Vergleichsmechanik. Smoke-Workbook vollstaendig importiert, alle vier Blaetter gerendert und null Formelfehler gefunden. Beide Produktionsbuilds erfolgreich; neuer Test 3/3 und der zuvor unter Last abgebrochene fachfremde `sheet-selection-cost`-Test isoliert 1/1 erfolgreich. Gesamtsuite: 1.331 bestanden, zehn uebersprungen, ein vorhandener UI-Test ueberschreitet unter Gesamtsuitelast reproduzierbar sein 5-Sekunden-Limit. Keine Datenbank-/Indexmutation, keine Mappingentscheidung, kein Manifestbump oder Reimport. |
|
||||
| 2026-09-18 | Phase 13D, Full-Context-Familienfusion ohne Reranker: Die drei mit vollstaendigem Findingtext ausgefuehrten Abschnittssuchen werden zu einer Control-Rangliste verdichtet und tragen neben Problem- und optionaler Remediationrangliste genau eine Stimme zur finalen RRF bei. Abschnittstreffer duerfen damit wieder in die Zwolf-Control-Shortlist gelangen, ohne korrelierte Dreifachstimmen. Retrievalversion `hybrid-finding-query-section-family-rrf-1.6.0`; Queryschema, Prompt, Kandidatenbewertung, deterministische Auswahl und Human Review unveraendert. 1.329 Tests bestanden, zehn opt-in Integrationen uebersprungen; beide Typechecks, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Datenbank-/Indexmutation, kein Manifestbump oder Reimport; reale Qualitaetsbewertung bleibt dem naechsten manuellen Scannervergleich vorbehalten. |
|
||||
| 2026-09-18 | Phase 13D, Finding-only Retrieval ohne Reranker: Queryschema 4 baut Problem- und optionale Remediationquery nur aus privacy-safe Titel, Beschreibung und Empfehlung; Tool, Rule-ID, Katalog und `public-security-topics.ts` sind aus dem regulatorischen Pfad entfernt. Hybrid-RRF und Query-RRF deduplizieren auf Control-Ebene; Abschnittssuchen liefern nur Evidenz fuer bereits ausgewaehlte Controls. Retrievalversion `hybrid-finding-query-control-rrf-1.5.0`; produktive Runtime ohne Reranker. 1.328 Tests bestanden, zehn opt-in Integrationen uebersprungen; beide Typechecks, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Datenbank-/Indexmutation, kein Manifestbump oder Reimport. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Accepted Risk: decision record and storage contract
|
||||
|
||||
Status: implemented local documentation workflow; authenticated organizational approval remains a server-readiness requirement.
|
||||
Status: implemented whole-finding and Defender device-scoped application workflows; authenticated organizational approval remains a phase-14 server-readiness requirement.
|
||||
|
||||
## Purpose and basis
|
||||
|
||||
|
|
@ -22,6 +22,8 @@ User decision, 2026-09-10: no existing organizational approval rules; provide a
|
|||
- Applicable policy, regulatory and contractual considerations and any consulted specialists or restrictions.
|
||||
- Additional evidence references, recorder name, revision reason, server recording timestamp and complete prior decision snapshots.
|
||||
|
||||
For Microsoft Defender for IoT, the user-entered scope narrative is separate from an immutable system target. A `device-finding` target captures one exact stable device identity and one canonical CVE/finding definition. A `device-snapshot` target captures one exact stable device identity and every detailed actionable CVE in the selected active scan-run scope at decision time. Each covered member retains its canonical definition/CVE plus occurrence, scan-run, import-file, source-table and row provenance.
|
||||
|
||||
Evidence references are stored as inert text. The form neither uploads documents nor verifies their contents, signatures or availability. Source finding values and scanner provenance remain in the existing canonical model.
|
||||
|
||||
## States and rules
|
||||
|
|
@ -32,19 +34,25 @@ Displayed states are derived at read/render time: Not recorded, Draft, Scheduled
|
|||
|
||||
The existing review workflow (including the historical `Actionable` → `accepted` mapping) is separate and is not repurposed for risk acceptance. Ordinary review edits preserve the structured record. Accepted Risk does not automatically alter bookmarks, remediation status or technical dashboard numbers.
|
||||
|
||||
Effective Defender device/CVE coverage is the union of independent valid decisions. A current whole-finding acceptance covers every affected device for that CVE; a device-finding decision covers one device/CVE pair; a device snapshot covers only its captured CVE definitions. `Accepted` and visibly due `Review due` decisions provide current coverage. Draft, Scheduled, Expired, Revoked and Review required decisions do not. Revoking one overlapping decision never revokes another.
|
||||
|
||||
## Implemented persistence
|
||||
|
||||
The authoritative record is `FindingReview.riskAcceptance`, an additive version-1 JSON object stored in the existing `assessor_reviews.data` JSONB column. It contains an append-only `revisions` array. Each revision contains action, complete details, sequential revision number, recorder, reason, original target occurrence ID, server-generated timestamp and `approvalIdentityVerified: false`.
|
||||
The authoritative whole-finding record remains `FindingReview.riskAcceptance`, an additive version-1 JSON object stored in the existing `assessor_reviews.data` JSONB column. It contains an append-only `revisions` array. Each revision contains action, complete details, sequential revision number, recorder, reason, original target occurrence ID, server-generated timestamp and `approvalIdentityVerified: false`.
|
||||
|
||||
No SQL migration is needed for this implementation. The canonical local datastore remains Docker project `assessor-2_0`, container `assessor-2_0-postgres-1`, database `assessor`, volume `assessor-2_0_assessor-postgres-data`, `127.0.0.1:5432`. The browser never accesses PostgreSQL directly.
|
||||
Migration 028 adds the project-owned `assessor_scoped_risk_acceptances` table for device-scoped histories. Its indexed immutable target snapshot is stored with revision, timestamps and the complete append-only decision history in JSONB; project deletion cascades. Existing `FindingReview.riskAcceptance` records are unchanged and require no migration. Before applying 028, the canonical datastore was saved to `backups/assessor-pre-028-20260921.backup`; its custom-format archive catalog was readable and its SHA-256 is `0828CEEA875A00DF4D9613737445C4837D208FBD6D96EEFA988F6745FDAE4946`. The canonical local datastore remains Docker project `assessor-2_0`, container `assessor-2_0-postgres-1`, database `assessor`, volume `assessor-2_0_assessor-postgres-data`, `127.0.0.1:5432`. The browser never accesses PostgreSQL directly.
|
||||
|
||||
Writes use `POST /api/v1/projects/:projectId/risk-acceptances` with occurrence ID, command and expected review revision. The backend verifies project membership and validates the decision. PostgreSQL locks the occurrence and current review within the repository transaction, appends the snapshot and increments the review/repository revisions atomically. The local reference repository implements the same contract. General `/reviews` writes cannot introduce, replace, remove or retarget a risk acceptance history. Conflicts fail without overwriting the previous record.
|
||||
|
||||
Scoped reads and writes use `GET` and `POST /api/v1/projects/:projectId/risk-acceptances/scoped`. Creation accepts only a device-finding occurrence/device reference or a device-snapshot scan-run/device reference. The server verifies project membership, reconstructs targets through the shared Defender identity model, rejects unresolved or ambiguous identities, and derives the complete nonempty snapshot membership itself. Existing scoped decisions accept only revision commands: their target cannot be replaced. PostgreSQL row locks and optimistic revisions make grouped accept/revoke changes atomic. Ordinary review endpoints never write the scoped table. Project export includes both legacy and scoped histories, and application state loads scoped histories with the project workspace.
|
||||
|
||||
The UI reports successful storage only after the backend acknowledges the write. Disconnected/unsaved findings cannot record acceptance. Failed writes retain the dialog entries. Existing browser review caches and project snapshots can retain a copy; they are not the authority for acceptance. Exports include the complete structured review record. Existing project/scan deletion policies still apply: this is not an independent legal-hold archive.
|
||||
|
||||
## Reimport and retention
|
||||
|
||||
The existing stable-identity review transfer preserves the complete object and all original revision targets. It never matches by title or row position. If a replacement occurrence differs from the recorded target, the preserved acceptance displays Review required. A new decision can explicitly cover the replacement after reassessment. Failed or ambiguous matches retain the original review according to the reimport policy.
|
||||
The existing stable-identity review transfer preserves the complete whole-finding object and all original revision targets. It never matches by title or row position. If a replacement occurrence differs from the recorded target, the preserved acceptance displays Review required. A new decision can explicitly cover the replacement after reassessment. Failed or ambiguous matches retain the original review according to the reimport policy.
|
||||
|
||||
Scoped records are project-owned and do not transfer between projects. On a later import they provide coverage only when the exact stable Defender device key and canonical CVE definition both match a captured member. A new CVE, changed device key, unresolved identity or ambiguous identity is not carried forward. The snapshot itself never grows after creation. Existing datasets need no reimport when their retained Defender source observations can reconstruct the target; aggregate-only or older evidence without detailed CVE/device identity cannot be scoped and the action remains unavailable.
|
||||
|
||||
This refines preservation in `docs/REIMPORT_POLICY.md`: preserving a prior decision is not proof that a changed finding or new assessment is within its approved scope. Previous approvals, expiry dates and decision evidence must never be silently renewed.
|
||||
|
||||
|
|
@ -55,7 +63,7 @@ The current local-only application has no authenticated approver identity, organ
|
|||
Before organization-wide approval in the browser/server target, add:
|
||||
|
||||
1. SSO-bound actors and client-scoped authorization; configurable risk-owner/approver roles, thresholds, separation of duties and delegation rules.
|
||||
2. Dedicated `risk_acceptances`, `risk_acceptance_revisions`, `risk_acceptance_approvals` and `risk_acceptance_evidence` tables with immutable event IDs, project/occurrence foreign keys, actor IDs and server timestamps. Migrate current JSON history without replacing historical declared identities with verified identities.
|
||||
2. Phase-14 governance tables for authenticated actors, immutable approval events and evidence records. Migration 028 is only the current project-owned scoped decision snapshot/history store; it does not provide authenticated actors, signing or legal retention. Any future migration must preserve both legacy review JSON and scoped histories without replacing historical declared identities with verified identities.
|
||||
3. Evidence references with content hashes, access controls, immutable versions and an organization-approved retention/legal-hold policy. Do not add external storage or uploads without a security-model update.
|
||||
4. Transactional approval/revocation endpoints, tamper-evident audit retention, server-side due/expiry queries and an explicitly configured notification/monitoring workflow.
|
||||
5. Tests against a dedicated approved integration environment for concurrent approvals, permissions, clock boundaries, evidence retention and migration round-trips. An approved persistence migration requires a verified backup of the canonical datastore first.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RiskAcceptanceValidationError, type RiskAcceptanceCommand } from '../src/domain/risk-acceptance';
|
||||
import type { ScopedRiskAcceptanceWrite } from '../src/domain/scoped-risk-acceptance';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
|
|
@ -1004,6 +1005,23 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
|||
await repository.saveReview(body.review, body.expectedRevision);
|
||||
status = 200; sendJson(response, status, { review: body.review, repositoryRevision: repository.revision }, requestId); return;
|
||||
}
|
||||
if (segments.length === 4 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && segments[3] === 'scoped' && method === 'GET') {
|
||||
status = 200; sendJson(response, status, await repository.listScopedRiskAcceptances(segments[1]), requestId); return;
|
||||
}
|
||||
if (segments.length === 4 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && segments[3] === 'scoped' && method === 'POST') {
|
||||
const body = await readJson<ScopedRiskAcceptanceWrite>(request, maximumBodyBytes);
|
||||
const target = body?.target;
|
||||
const invalidTarget = target && (typeof target.deviceKey !== 'string' || !target.deviceKey.trim()
|
||||
|| (target.kind === 'device-finding' ? typeof target.occurrenceId !== 'string' || !target.occurrenceId.trim()
|
||||
: target.kind === 'device-snapshot' ? !Array.isArray(target.scanRunIds) || !target.scanRunIds.length || target.scanRunIds.some(id => typeof id !== 'string' || !id.trim())
|
||||
: true));
|
||||
if (!body || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 || !body.command
|
||||
|| (body.acceptanceId !== undefined && (typeof body.acceptanceId !== 'string' || !body.acceptanceId.trim())) || invalidTarget) {
|
||||
throw new HttpError(400, 'invalid_scoped_risk_acceptance', 'A scoped risk command and expected revision are required.');
|
||||
}
|
||||
const acceptance = await repository.recordScopedRiskAcceptance(segments[1], body);
|
||||
status = 200; sendJson(response, status, { acceptance, repositoryRevision: repository.revision }, requestId); return;
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && method === 'POST') {
|
||||
const body = await readJson<{ occurrenceId: string; command: RiskAcceptanceCommand; expectedRevision: number }>(request, maximumBodyBytes);
|
||||
if (typeof body.occurrenceId !== 'string' || !body.occurrenceId || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) throw new HttpError(400, 'invalid_risk_acceptance', 'Finding and expected review revision are required.');
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import aiOverviewRuns from './migrations/024_ai_overview_runs.sql?raw';
|
|||
import regulatoryMapping from './migrations/025_regulatory_mapping.sql?raw';
|
||||
import globalRegulatoryMapping from './migrations/026_global_regulatory_mapping.sql?raw';
|
||||
import regulatoryMappingBatches from './migrations/027_regulatory_mapping_batches.sql?raw';
|
||||
import scopedRiskAcceptances from './migrations/028_scoped_risk_acceptances.sql?raw';
|
||||
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
|
||||
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
|
||||
|
||||
|
|
@ -59,5 +60,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
|||
{ version: '024', name: 'ai-overview-runs', sql: aiOverviewRuns },
|
||||
{ version: '025', name: 'regulatory-mapping', sql: regulatoryMapping },
|
||||
{ version: '026', name: 'global-regulatory-mapping', sql: globalRegulatoryMapping },
|
||||
{ version: '027', name: 'regulatory-mapping-batches', sql: regulatoryMappingBatches }
|
||||
{ version: '027', name: 'regulatory-mapping-batches', sql: regulatoryMappingBatches },
|
||||
{ version: '028', name: 'scoped-risk-acceptances', sql: scopedRiskAcceptances }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
CREATE TABLE assessor_scoped_risk_acceptances (
|
||||
id text PRIMARY KEY CHECK (btrim(id) <> ''),
|
||||
project_id text NOT NULL REFERENCES assessor_projects(id) ON DELETE CASCADE,
|
||||
scope_kind text NOT NULL CHECK (scope_kind IN ('device-finding', 'device-snapshot')),
|
||||
device_key text NOT NULL CHECK (btrim(device_key) <> ''),
|
||||
revision integer NOT NULL CHECK (revision >= 1),
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX assessor_scoped_risk_acceptances_project_device_idx
|
||||
ON assessor_scoped_risk_acceptances (project_id, device_key, updated_at DESC, id);
|
||||
|
||||
COMMENT ON TABLE assessor_scoped_risk_acceptances IS
|
||||
'Project-owned append-only decision histories for immutable Defender device/finding target snapshots.';
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../../src/domain/risk-acceptance';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
appendScopedRiskAcceptance,
|
||||
scopedRiskTargetFromOccurrences,
|
||||
type ScopedRiskAcceptance,
|
||||
type ScopedRiskAcceptanceTarget,
|
||||
type ScopedRiskAcceptanceWrite
|
||||
} from '../../src/domain/scoped-risk-acceptance';
|
||||
import type { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
|
||||
import type {
|
||||
Assessment,
|
||||
|
|
@ -378,6 +386,7 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record<string, DbStructureLayerId> = {
|
|||
assessor_finding_occurrence_categories: 'result',
|
||||
assessor_evidence: 'result',
|
||||
assessor_reviews: 'governance',
|
||||
assessor_scoped_risk_acceptances: 'governance',
|
||||
assessor_comparisons: 'governance',
|
||||
assessor_projects: 'core',
|
||||
assessor_assessments: 'core',
|
||||
|
|
@ -2511,6 +2520,69 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
|||
});
|
||||
}
|
||||
|
||||
async recordScopedRiskAcceptance(projectId: string, write: ScopedRiskAcceptanceWrite): Promise<ScopedRiskAcceptance> {
|
||||
return this.mutate(async client => {
|
||||
const existing = write.acceptanceId
|
||||
? await client.query<DataRow<ScopedRiskAcceptance>>(`SELECT data FROM assessor_scoped_risk_acceptances
|
||||
WHERE id=$1 AND project_id=$2 FOR UPDATE`, [write.acceptanceId, projectId])
|
||||
: undefined;
|
||||
const current = existing?.rows[0]?.data;
|
||||
if (write.acceptanceId && !current) throw new RepositoryNotFoundError('Scoped risk acceptance is not available.');
|
||||
if (!Number.isSafeInteger(write.expectedRevision) || write.expectedRevision !== (current?.revision ?? 0)) {
|
||||
throw new RepositoryConflictError('The scoped risk acceptance changed. Reload it before recording a decision.');
|
||||
}
|
||||
let target: ScopedRiskAcceptanceTarget;
|
||||
if (current) {
|
||||
if (write.target) throw new RepositoryValidationError('An existing scoped risk acceptance cannot be retargeted.');
|
||||
target = current.target;
|
||||
} else {
|
||||
if (!write.target) throw new RepositoryValidationError('A scoped risk target is required.');
|
||||
let occurrences: FindingOccurrence[];
|
||||
let memberOccurrenceIds: Set<string> | undefined;
|
||||
if (write.target.kind === 'device-finding') {
|
||||
const result = await client.query<DataRow<FindingOccurrence>>(`SELECT o.data FROM assessor_finding_occurrences o
|
||||
JOIN assessor_scan_runs r ON r.id=o.scan_run_id JOIN assessor_assessments a ON a.id=r.assessment_id
|
||||
WHERE o.id=$1 AND a.project_id=$2 FOR UPDATE OF o`, [write.target.occurrenceId, projectId]);
|
||||
const selected = dataRows(result)[0];
|
||||
occurrences = selected ? dataRows(await client.query<DataRow<FindingOccurrence>>(`SELECT o.data FROM assessor_finding_occurrences o
|
||||
JOIN assessor_scan_runs r ON r.id=o.scan_run_id JOIN assessor_assessments a ON a.id=r.assessment_id
|
||||
WHERE o.scan_run_id=$1 AND a.project_id=$2 ORDER BY o.id FOR UPDATE OF o`, [selected.scanRunId, projectId])) : [];
|
||||
memberOccurrenceIds = selected ? new Set([selected.id]) : undefined;
|
||||
} else {
|
||||
if (!write.target.scanRunIds.length) throw new RepositoryValidationError('A non-empty scan-run scope is required.');
|
||||
const runCheck = await client.query<{ id: string } & QueryResultRow>(`SELECT r.id FROM assessor_scan_runs r
|
||||
JOIN assessor_assessments a ON a.id=r.assessment_id WHERE a.project_id=$1 AND r.id=ANY($2::text[]) FOR UPDATE OF r`,
|
||||
[projectId, write.target.scanRunIds]);
|
||||
if (new Set(runCheck.rows.map(row => row.id)).size !== new Set(write.target.scanRunIds).size) {
|
||||
throw new RepositoryValidationError('The selected scan-run scope is not valid for this project.');
|
||||
}
|
||||
occurrences = dataRows(await client.query<DataRow<FindingOccurrence>>(`SELECT o.data FROM assessor_finding_occurrences o
|
||||
JOIN assessor_scan_runs r ON r.id=o.scan_run_id JOIN assessor_assessments a ON a.id=r.assessment_id
|
||||
WHERE a.project_id=$1 AND o.scan_run_id=ANY($2::text[]) ORDER BY o.id FOR UPDATE OF o`, [projectId, write.target.scanRunIds]));
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
try { target = scopedRiskTargetFromOccurrences(write.target.kind, write.target.deviceKey, occurrences, now, memberOccurrenceIds); }
|
||||
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Invalid scoped risk target.'); }
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
let saved: ScopedRiskAcceptance;
|
||||
try { saved = appendScopedRiskAcceptance(current, projectId, current?.id ?? `scoped-risk-${randomUUID()}`, target, write.command, now); }
|
||||
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Invalid scoped risk acceptance.'); }
|
||||
await client.query(`INSERT INTO assessor_scoped_risk_acceptances
|
||||
(id, project_id, scope_kind, device_key, revision, created_at, updated_at, data)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET revision=EXCLUDED.revision, updated_at=EXCLUDED.updated_at, data=EXCLUDED.data`,
|
||||
[saved.id, projectId, saved.target.kind, saved.target.device.key, saved.revision, saved.createdAt, saved.updatedAt, json(saved)]);
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
async listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]> {
|
||||
return dataRows(await this.pool.query<DataRow<ScopedRiskAcceptance>>(
|
||||
'SELECT data FROM assessor_scoped_risk_acceptances WHERE project_id=$1 ORDER BY updated_at DESC, id', [projectId]
|
||||
));
|
||||
}
|
||||
|
||||
async listReviews(projectId: string): Promise<FindingReview[]> {
|
||||
return dataRows(await this.pool.query<DataRow<FindingReview>>(`
|
||||
SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv
|
||||
|
|
@ -2627,6 +2699,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
|||
.sort((left, right) => left.cveId.localeCompare(right.cveId)),
|
||||
evidence: models.flatMap(model => model.evidence),
|
||||
reviews: await this.listReviews(projectId),
|
||||
scopedRiskAcceptances: await this.listScopedRiskAcceptances(projectId),
|
||||
diagnostics: models.flatMap(model => model.diagnostics),
|
||||
comparisons: dataRows(await this.pool.query<DataRow<StoredComparison>>(
|
||||
'SELECT data FROM assessor_comparisons WHERE project_id = $1 ORDER BY id', [projectId]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||
import type { ScopedRiskAcceptance, ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance';
|
||||
import type { Assessment, AssessmentProject, CanonicalAssessmentModel, FindingReview, ImportFileRecord } from '../domain/canonical-model';
|
||||
import type {
|
||||
UnifiedResultCategory,
|
||||
|
|
@ -231,6 +232,12 @@ export class AssessorApiClient {
|
|||
recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<{ review: FindingReview; repositoryRevision: number }> {
|
||||
return this.request(`/projects/${encodeURIComponent(projectId)}/risk-acceptances`, { method: 'POST', body: JSON.stringify({ occurrenceId, command, expectedRevision }) });
|
||||
}
|
||||
listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]> {
|
||||
return this.request(`/projects/${encodeURIComponent(projectId)}/risk-acceptances/scoped`);
|
||||
}
|
||||
recordScopedRiskAcceptance(projectId: string, write: ScopedRiskAcceptanceWrite): Promise<{ acceptance: ScopedRiskAcceptance; repositoryRevision: number }> {
|
||||
return this.request(`/projects/${encodeURIComponent(projectId)}/risk-acceptances/scoped`, { method: 'POST', body: JSON.stringify(write) });
|
||||
}
|
||||
|
||||
queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
||||
return this.request(unifiedQueryPath('/results', query));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { AssessorApiClient } from '../api/assessor-api-client';
|
|||
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
|
||||
import type { FindingReview } from '../domain/canonical-model';
|
||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||
import type { ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance';
|
||||
import { defaultReview } from '../domain/review';
|
||||
import type { ImportProfile } from '../domain/import-profile';
|
||||
import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow';
|
||||
|
|
@ -45,6 +46,7 @@ interface AppContextValue extends AppState {
|
|||
closeImportWorkflow(): void;
|
||||
setActiveSheet(sheetId: string): void;
|
||||
recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise<void>;
|
||||
recordScopedRiskAcceptance(write: ScopedRiskAcceptanceWrite): Promise<void>;
|
||||
setView(view: ViewMode): void;
|
||||
setSearch(search: string): void;
|
||||
setSortColumn(column: string): void;
|
||||
|
|
@ -222,7 +224,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
const activeSheet = snapshot.dataset.sheets[0];
|
||||
const defaults = profile.defaults(activeSheet);
|
||||
setState(current => ({
|
||||
...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews,
|
||||
...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews, scopedRiskAcceptances: [],
|
||||
projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn,
|
||||
sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [],
|
||||
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
||||
|
|
@ -313,9 +315,9 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
const report = createImportReport(preview, importedIds);
|
||||
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
|
||||
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
|
||||
const persistedWorkspace = persistedProjectId
|
||||
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
|
||||
: undefined;
|
||||
const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId
|
||||
? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])])
|
||||
: [undefined, []];
|
||||
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
||||
const localReviews = await reviewRepository.load(projectId);
|
||||
const hydrated = persistedWorkspace
|
||||
|
|
@ -327,7 +329,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
const activeProfile = profileFor(workDataset);
|
||||
const defaults = activeProfile.defaults(activeSheet);
|
||||
setState(current => ({
|
||||
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false,
|
||||
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false,
|
||||
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
|
||||
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
||||
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
||||
|
|
@ -424,9 +426,9 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
const report = createImportReport(preview, importedIds);
|
||||
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
|
||||
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
|
||||
const persistedWorkspace = persistedProjectId
|
||||
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
|
||||
: undefined;
|
||||
const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId
|
||||
? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])])
|
||||
: [undefined, []];
|
||||
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
||||
const localReviews = await reviewRepository.load(projectId);
|
||||
const hydrated = persistedWorkspace
|
||||
|
|
@ -438,7 +440,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
const activeProfile = profileFor(workDataset);
|
||||
const defaults = activeProfile.defaults(activeSheet);
|
||||
setState(current => ({
|
||||
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false,
|
||||
...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false,
|
||||
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
|
||||
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
||||
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
||||
|
|
@ -477,19 +479,22 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
status: `Loading Client: ${loadingClientName}`
|
||||
}));
|
||||
try {
|
||||
const { models, dataset, assessmentIds } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds);
|
||||
const [{ models, dataset, assessmentIds }, scopedRiskAcceptances] = await Promise.all([
|
||||
loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds),
|
||||
assessorApi.listScopedRiskAcceptances(projectId).catch(() => [])
|
||||
]);
|
||||
if (requestId !== openProjectRequest.current) return false;
|
||||
if (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name };
|
||||
if (!models.length) {
|
||||
persistedReviews.current = new Map();
|
||||
setState(current => ({ ...current, status: 'This client does not have a scan report yet.' }));
|
||||
setState(current => ({ ...current, scopedRiskAcceptances, status: 'This client does not have a scan report yet.' }));
|
||||
return false;
|
||||
}
|
||||
if (!dataset) {
|
||||
persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review]));
|
||||
setActiveAssessmentIds(assessmentIds);
|
||||
setWorkScopeId(scopeId);
|
||||
setState(current => ({ ...current, reviews: {}, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' }));
|
||||
setState(current => ({ ...current, reviews: {}, scopedRiskAcceptances, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' }));
|
||||
return true;
|
||||
}
|
||||
const localReviews = await reviewRepository.load(scopeId);
|
||||
|
|
@ -505,6 +510,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
dataset,
|
||||
activeSheetId: activeSheet?.id ?? '',
|
||||
reviews,
|
||||
scopedRiskAcceptances,
|
||||
projectId,
|
||||
search: '',
|
||||
actionOnly: false,
|
||||
|
|
@ -644,6 +650,14 @@ export function AppProvider({ children }: PropsWithChildren) {
|
|||
reviewSaveQueues.current.set(key, queued);
|
||||
return queued;
|
||||
},
|
||||
recordScopedRiskAcceptance: async write => {
|
||||
if (!targetProjectId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved Defender scope before recording risk acceptance.');
|
||||
const saved = await assessorApi.recordScopedRiskAcceptance(targetProjectId, write);
|
||||
setState(current => ({
|
||||
...current,
|
||||
scopedRiskAcceptances: [saved.acceptance, ...current.scopedRiskAcceptances.filter(item => item.id !== saved.acceptance.id)]
|
||||
}));
|
||||
},
|
||||
snapshot: () => state.dataset ? createSnapshot(state.dataset, state.reviews) : null,
|
||||
setTheme,
|
||||
setDelimiter: delimiter => setState(current => ({ ...current, delimiter })),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ImportedDataset } from '../domain/dataset';
|
||||
import type { Review } from '../domain/review';
|
||||
import type { ScopedRiskAcceptance } from '../domain/scoped-risk-acceptance';
|
||||
import type { DynamicFacetFilters } from '../features/search-filter/dynamic-facets';
|
||||
|
||||
export type ViewMode = 'records' | 'columns' | 'table' | 'raw';
|
||||
|
|
@ -20,6 +21,7 @@ export interface AppState {
|
|||
actionOnly: boolean;
|
||||
selectedRecordId: string;
|
||||
reviews: Record<string, Review>;
|
||||
scopedRiskAcceptances: ScopedRiskAcceptance[];
|
||||
projectId: string;
|
||||
status: string;
|
||||
theme: ThemeMode;
|
||||
|
|
@ -57,6 +59,7 @@ export const initialAppState: AppState = {
|
|||
actionOnly: false,
|
||||
selectedRecordId: '',
|
||||
reviews: {},
|
||||
scopedRiskAcceptances: [],
|
||||
projectId: '',
|
||||
status: 'Ready',
|
||||
theme: 'dark',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
import type { FindingOccurrence, JsonValue } from './canonical-model';
|
||||
import { buildDefenderVulnerabilityModel, type DefenderDevice, type DefenderSourceRow } from './defender-for-iot';
|
||||
import type { FindingRecord } from './finding';
|
||||
import {
|
||||
emptyRiskAcceptanceDetails,
|
||||
riskAcceptanceState,
|
||||
riskAcceptanceIssues,
|
||||
type RiskAcceptance,
|
||||
type RiskAcceptanceAction,
|
||||
type RiskAcceptanceCommand,
|
||||
type RiskAcceptanceDetails
|
||||
} from './risk-acceptance';
|
||||
|
||||
export type ScopedRiskAcceptanceKind = 'device-finding' | 'device-snapshot';
|
||||
|
||||
export interface ScopedRiskAcceptanceMember {
|
||||
findingDefinitionId: string;
|
||||
cve: string;
|
||||
capturedOccurrences: Array<{
|
||||
occurrenceId: string;
|
||||
scanRunId: string;
|
||||
importFileId: string;
|
||||
sourceTableId?: string;
|
||||
sourcePosition: FindingOccurrence['sourcePosition'];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ScopedRiskAcceptanceDevice {
|
||||
key: string;
|
||||
displayName: string;
|
||||
ip: string;
|
||||
site: string;
|
||||
identityMethod: DefenderDevice['identityMethod'];
|
||||
identityConfidence: DefenderDevice['identityConfidence'];
|
||||
}
|
||||
|
||||
export interface ScopedRiskAcceptanceTarget {
|
||||
kind: ScopedRiskAcceptanceKind;
|
||||
device: ScopedRiskAcceptanceDevice;
|
||||
members: ScopedRiskAcceptanceMember[];
|
||||
capturedScanRunIds: string[];
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface ScopedRiskAcceptanceRevision extends RiskAcceptanceCommand {
|
||||
revision: number;
|
||||
recordedAt: string;
|
||||
approvalIdentityVerified: false;
|
||||
}
|
||||
|
||||
export interface ScopedRiskAcceptance {
|
||||
version: 1;
|
||||
id: string;
|
||||
projectId: string;
|
||||
target: ScopedRiskAcceptanceTarget;
|
||||
revisions: ScopedRiskAcceptanceRevision[];
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScopedRiskAcceptanceWrite {
|
||||
acceptanceId?: string;
|
||||
target?:
|
||||
| { kind: 'device-finding'; occurrenceId: string; deviceKey: string }
|
||||
| { kind: 'device-snapshot'; scanRunIds: string[]; deviceKey: string };
|
||||
command: RiskAcceptanceCommand;
|
||||
expectedRevision: number;
|
||||
}
|
||||
|
||||
export interface DefenderRiskTarget {
|
||||
recordId: string;
|
||||
occurrenceId: string;
|
||||
definitionId: string;
|
||||
scanRunId: string;
|
||||
importFileId?: string;
|
||||
sourceTableId?: string;
|
||||
sourcePosition?: FindingOccurrence['sourcePosition'];
|
||||
cve: string;
|
||||
device: ScopedRiskAcceptanceDevice;
|
||||
eligible: boolean;
|
||||
unavailableReason?: string;
|
||||
}
|
||||
|
||||
function text(value: JsonValue | undefined): string {
|
||||
return typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
function object(value: JsonValue | undefined): Record<string, JsonValue> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, JsonValue> : {};
|
||||
}
|
||||
|
||||
function observationRows(value: JsonValue): DefenderSourceRow[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry, index) => {
|
||||
const observation = object(entry);
|
||||
if (!Object.keys(observation).length) return [];
|
||||
const source = object(observation.source);
|
||||
const additional = object(observation.additionalSourceFields);
|
||||
return [{
|
||||
tableIndex: typeof source.tableIndex === 'number' ? source.tableIndex : 0,
|
||||
rowIndex: typeof source.rowIndex === 'number' ? source.rowIndex : index,
|
||||
fields: {
|
||||
Name: observation.name ?? '', IP: observation.ip ?? '', Site: observation.site ?? '', Sensor: observation.sensor ?? '',
|
||||
CVE: observation.cve ?? '', Severity: observation.severity ?? '', Beschreibung: observation.description ?? '', ...additional
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function devicesForObservations(value: JsonValue): Array<{ device: ScopedRiskAcceptanceDevice; eligible: boolean; unavailableReason?: string }> {
|
||||
const rows = observationRows(value);
|
||||
if (!rows.length) return [];
|
||||
const model = buildDefenderVulnerabilityModel([{
|
||||
tableIndex: 0,
|
||||
name: 'Scoped risk target',
|
||||
headers: ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'Beschreibung'],
|
||||
rows
|
||||
}]);
|
||||
const ambiguous = new Set(model.diagnostics
|
||||
.filter(item => item.code === 'defender-device-identity-ambiguous')
|
||||
.map(item => text(item.details?.deviceId)));
|
||||
return model.devices.filter(device => device.occurrenceIds.length > 0).map(device => {
|
||||
const reason = device.identityMethod === 'unresolved'
|
||||
? 'The source does not provide a stable device identity.'
|
||||
: ambiguous.has(device.id)
|
||||
? 'The source contains conflicting identity values for this device.'
|
||||
: undefined;
|
||||
return {
|
||||
device: {
|
||||
key: device.id,
|
||||
displayName: device.displayName || device.id,
|
||||
ip: device.ip,
|
||||
site: device.site,
|
||||
identityMethod: device.identityMethod,
|
||||
identityConfidence: device.identityConfidence
|
||||
},
|
||||
eligible: !reason,
|
||||
unavailableReason: reason
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sourceObservationsForOccurrence(occurrence: FindingOccurrence): JsonValue | undefined {
|
||||
return occurrence.originalFields.find(field => field.key === 'source-observations'
|
||||
|| /^Source Observations$/i.test(field.label.trim()))?.value;
|
||||
}
|
||||
|
||||
export function defenderRiskTargetsForOccurrence(occurrence: FindingOccurrence): DefenderRiskTarget[] {
|
||||
return defenderRiskTargetsForOccurrences([occurrence]);
|
||||
}
|
||||
|
||||
/** Rebuilds device identity across each complete Defender scan run before assigning CVEs. */
|
||||
export function defenderRiskTargetsForOccurrences(occurrences: FindingOccurrence[]): DefenderRiskTarget[] {
|
||||
const result: DefenderRiskTarget[] = [];
|
||||
const byRun = new Map<string, FindingOccurrence[]>();
|
||||
for (const occurrence of occurrences) byRun.set(occurrence.scanRunId, [...(byRun.get(occurrence.scanRunId) ?? []), occurrence]);
|
||||
for (const runOccurrences of byRun.values()) {
|
||||
const eligible = runOccurrences.flatMap(occurrence => {
|
||||
if (occurrence.sourceIdentity.tool.id !== 'defender-for-iot-vulnerabilities') return [];
|
||||
const cve = (occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId ?? '').trim().toLocaleUpperCase('en-US');
|
||||
const source = sourceObservationsForOccurrence(occurrence);
|
||||
return /^CVE-\d{4}-\d{4,}$/.test(cve) && source !== undefined ? [{ occurrence, cve, rows: observationRows(source) }] : [];
|
||||
});
|
||||
const rows = eligible.flatMap(item => item.rows);
|
||||
if (!rows.length) continue;
|
||||
const model = buildDefenderVulnerabilityModel([{
|
||||
tableIndex: 0,
|
||||
name: 'Scoped risk targets',
|
||||
headers: ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'Beschreibung'],
|
||||
rows
|
||||
}]);
|
||||
const ambiguous = new Set(model.diagnostics
|
||||
.filter(item => item.code === 'defender-device-identity-ambiguous')
|
||||
.map(item => text(item.details?.deviceId)));
|
||||
const devices = new Map(model.devices.map(device => [device.id, device]));
|
||||
for (const item of eligible) {
|
||||
const deviceIds = new Set(model.occurrences.filter(value => value.cve === item.cve).map(value => value.deviceId));
|
||||
for (const deviceId of deviceIds) {
|
||||
const device = devices.get(deviceId);
|
||||
if (!device) continue;
|
||||
const reason = device.identityMethod === 'unresolved'
|
||||
? 'The source does not provide a stable device identity.'
|
||||
: ambiguous.has(device.id)
|
||||
? 'The source contains conflicting identity values for this device.'
|
||||
: undefined;
|
||||
result.push({
|
||||
recordId: '', occurrenceId: item.occurrence.id, definitionId: item.occurrence.definitionId,
|
||||
scanRunId: item.occurrence.scanRunId, importFileId: item.occurrence.importFileId,
|
||||
sourceTableId: item.occurrence.sourceTableId, sourcePosition: item.occurrence.sourcePosition,
|
||||
cve: item.cve,
|
||||
device: {
|
||||
key: device.id, displayName: device.displayName || device.id, ip: device.ip, site: device.site,
|
||||
identityMethod: device.identityMethod, identityConfidence: device.identityConfidence
|
||||
},
|
||||
eligible: !reason, unavailableReason: reason
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function defenderRiskTargetsForRecord(record: FindingRecord, toolId?: string): DefenderRiskTarget[] {
|
||||
if (toolId !== 'defender-for-iot-vulnerabilities' || !record.findingOccurrenceId || !record.findingDefinitionId || !record.scanRunId) return [];
|
||||
const field = record.fields.find(item => /^Source Observations$/i.test(item.label.trim()));
|
||||
if (!field?.value.trim()) return [];
|
||||
let parsed: JsonValue;
|
||||
try { parsed = JSON.parse(field.value) as JsonValue; } catch { return []; }
|
||||
const cve = record.fields.find(item => /^CVE$/i.test(item.label.trim()))?.value.trim().toLocaleUpperCase('en-US')
|
||||
|| record.title.trim().toLocaleUpperCase('en-US');
|
||||
if (!/^CVE-\d{4}-\d{4,}$/.test(cve)) return [];
|
||||
return devicesForObservations(parsed).map(item => ({
|
||||
recordId: record.id,
|
||||
occurrenceId: record.findingOccurrenceId!,
|
||||
definitionId: record.findingDefinitionId!,
|
||||
scanRunId: record.scanRunId!,
|
||||
importFileId: record.importFileId,
|
||||
cve,
|
||||
...item
|
||||
}));
|
||||
}
|
||||
|
||||
export function scopedRiskTargetFromOccurrences(
|
||||
kind: ScopedRiskAcceptanceKind,
|
||||
deviceKey: string,
|
||||
occurrences: FindingOccurrence[],
|
||||
now: string,
|
||||
memberOccurrenceIds?: ReadonlySet<string>
|
||||
): ScopedRiskAcceptanceTarget {
|
||||
const matches = defenderRiskTargetsForOccurrences(occurrences)
|
||||
.filter(item => item.device.key === deviceKey && (!memberOccurrenceIds || memberOccurrenceIds.has(item.occurrenceId)));
|
||||
if (!matches.length) throw new Error('The device is not an actionable member of the selected Defender finding scope.');
|
||||
const blocked = matches.find(item => !item.eligible);
|
||||
if (blocked) throw new Error(blocked.unavailableReason || 'The device cannot be used as a stable risk target.');
|
||||
const first = matches[0];
|
||||
const members = new Map<string, ScopedRiskAcceptanceMember>();
|
||||
for (const match of matches) {
|
||||
const existing = members.get(match.definitionId);
|
||||
if (existing && existing.cve !== match.cve) throw new Error('A canonical finding definition cannot cover conflicting CVE identities.');
|
||||
const member = existing ?? { findingDefinitionId: match.definitionId, cve: match.cve, capturedOccurrences: [] };
|
||||
if (!member.capturedOccurrences.some(item => item.occurrenceId === match.occurrenceId)) member.capturedOccurrences.push({
|
||||
occurrenceId: match.occurrenceId,
|
||||
scanRunId: match.scanRunId,
|
||||
importFileId: match.importFileId ?? '',
|
||||
sourceTableId: match.sourceTableId,
|
||||
sourcePosition: structuredClone(match.sourcePosition ?? {})
|
||||
});
|
||||
members.set(match.definitionId, member);
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
device: first.device,
|
||||
members: [...members.values()].map(item => ({ ...item, capturedOccurrences: item.capturedOccurrences
|
||||
.sort((left, right) => left.occurrenceId.localeCompare(right.occurrenceId)) }))
|
||||
.sort((left, right) => left.cve.localeCompare(right.cve)),
|
||||
capturedScanRunIds: [...new Set(matches.map(item => item.scanRunId))].sort(),
|
||||
capturedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
export function appendScopedRiskAcceptance(
|
||||
current: ScopedRiskAcceptance | undefined,
|
||||
projectId: string,
|
||||
id: string,
|
||||
target: ScopedRiskAcceptanceTarget,
|
||||
command: RiskAcceptanceCommand,
|
||||
now: string
|
||||
): ScopedRiskAcceptance {
|
||||
const issues = riskAcceptanceIssues(command, now.slice(0, 10));
|
||||
if (command.action === 'revoke' && !current?.revisions.some(item => item.action === 'accept')) issues.push('Only a previously accepted risk can be revoked.');
|
||||
if (current && JSON.stringify(current.target) !== JSON.stringify(target)) issues.push('A scoped risk acceptance cannot be retargeted.');
|
||||
if (issues.length) throw new Error(issues.join(' '));
|
||||
const previous = current?.revisions ?? [];
|
||||
const lastAccepted = [...previous].reverse().find(item => item.action === 'accept');
|
||||
const details = command.action === 'revoke'
|
||||
? structuredClone(lastAccepted!.details)
|
||||
: Object.fromEntries(Object.keys(emptyRiskAcceptanceDetails()).map(key => [key, command.details[key as keyof RiskAcceptanceDetails].trim()])) as unknown as RiskAcceptanceDetails;
|
||||
const revision: ScopedRiskAcceptanceRevision = {
|
||||
action: command.action as RiskAcceptanceAction,
|
||||
details,
|
||||
recordedBy: command.recordedBy.trim(),
|
||||
changeReason: command.changeReason.trim(),
|
||||
revision: previous.length + 1,
|
||||
recordedAt: now,
|
||||
approvalIdentityVerified: false
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
id,
|
||||
projectId,
|
||||
target: structuredClone(target),
|
||||
revisions: [...previous, revision],
|
||||
revision: (current?.revision ?? 0) + 1,
|
||||
createdAt: current?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
export function scopedRiskAcceptanceState(value: ScopedRiskAcceptance, today = new Date().toISOString().slice(0, 10)) {
|
||||
const latest = value.revisions.at(-1);
|
||||
if (!latest) return 'Not recorded' as const;
|
||||
if (latest.action === 'revoke') return 'Revoked' as const;
|
||||
if (latest.action === 'draft') return 'Draft' as const;
|
||||
if (latest.details.expiresOn < today) return 'Expired' as const;
|
||||
if (latest.details.reviewOn <= today) return 'Review due' as const;
|
||||
if (latest.details.validFrom > today) return 'Scheduled' as const;
|
||||
return 'Accepted' as const;
|
||||
}
|
||||
|
||||
export function scopedAcceptanceCovers(value: ScopedRiskAcceptance, target: Pick<DefenderRiskTarget, 'definitionId' | 'device'>, today?: string): boolean {
|
||||
const state = scopedRiskAcceptanceState(value, today);
|
||||
return (state === 'Accepted' || state === 'Review due')
|
||||
&& value.target.device.key === target.device.key
|
||||
&& value.target.members.some(member => member.findingDefinitionId === target.definitionId);
|
||||
}
|
||||
|
||||
export interface DefenderRiskCoverageDecision {
|
||||
kind: 'whole-finding' | ScopedRiskAcceptanceKind;
|
||||
id: string;
|
||||
state: 'Accepted' | 'Review due';
|
||||
}
|
||||
|
||||
export function defenderRiskCoverageDecisions(
|
||||
target: DefenderRiskTarget,
|
||||
wholeFinding: RiskAcceptance | undefined,
|
||||
scoped: ScopedRiskAcceptance[],
|
||||
today?: string
|
||||
): DefenderRiskCoverageDecision[] {
|
||||
const decisions: DefenderRiskCoverageDecision[] = [];
|
||||
const wholeState = wholeFinding ? riskAcceptanceState(wholeFinding, target.occurrenceId, today) : undefined;
|
||||
if (wholeState === 'Accepted' || wholeState === 'Review due') {
|
||||
decisions.push({ kind: 'whole-finding', id: `occurrence:${target.occurrenceId}`, state: wholeState });
|
||||
}
|
||||
for (const value of scoped) {
|
||||
const state = scopedRiskAcceptanceState(value, today);
|
||||
if ((state === 'Accepted' || state === 'Review due') && scopedAcceptanceCovers(value, target, today)) {
|
||||
decisions.push({ kind: value.target.kind, id: value.id, state });
|
||||
}
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
|
|
@ -1354,7 +1354,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
</nav> : undefined;
|
||||
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
||||
if (clientInfoView === 'defender' && hasDefender) {
|
||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />;
|
||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
|
||||
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
|
||||
onScopedRiskAcceptance={app.backendStatus === 'connected' ? write => app.recordScopedRiskAcceptance(write) : undefined} />;
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -1534,7 +1536,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
|
||||
<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,21 @@ import { useState } from 'react';
|
|||
import type { ImportedDataset } from '../../domain/dataset';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
|
||||
import { defenderRiskTargetsForRecord, scopedRiskAcceptanceState, type ScopedRiskAcceptance, type ScopedRiskAcceptanceRevision } from '../../domain/scoped-risk-acceptance';
|
||||
import type { Review } from '../../domain/review';
|
||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
|
||||
export interface RiskRegisterEntry {
|
||||
id: string;
|
||||
recordId: string;
|
||||
recordId?: string;
|
||||
occurrenceId: string;
|
||||
title: string;
|
||||
source: string;
|
||||
state: ReturnType<typeof riskAcceptanceState>;
|
||||
latest: RiskAcceptanceRevision;
|
||||
revisions: RiskAcceptanceRevision[];
|
||||
latest: RiskAcceptanceRevision | ScopedRiskAcceptanceRevision;
|
||||
revisions: Array<RiskAcceptanceRevision | ScopedRiskAcceptanceRevision>;
|
||||
device?: string;
|
||||
coveredCves?: string[];
|
||||
}
|
||||
|
||||
function sourceName(record: FindingRecord): string {
|
||||
|
|
@ -23,7 +26,7 @@ function sourceName(record: FindingRecord): string {
|
|||
}
|
||||
|
||||
/** One register row per canonical occurrence, even when several sheets project it. */
|
||||
export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record<string, Review>, today = new Date().toISOString().slice(0, 10)): RiskRegisterEntry[] {
|
||||
export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record<string, Review>, today = new Date().toISOString().slice(0, 10), scoped: ScopedRiskAcceptance[] = []): RiskRegisterEntry[] {
|
||||
const entries = new Map<string, RiskRegisterEntry>();
|
||||
dataset.sheets.forEach(sheet => sheet.analysis.records.forEach(record => {
|
||||
const value = reviews[record.id]?.riskAcceptance;
|
||||
|
|
@ -42,6 +45,28 @@ export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record<st
|
|||
revisions: value!.revisions
|
||||
});
|
||||
}));
|
||||
const targets = dataset.sheets.flatMap(sheet => sheet.analysis.records.flatMap(record => defenderRiskTargetsForRecord(record, sheet.toolId)));
|
||||
for (const value of scoped) {
|
||||
const latest = value.revisions.at(-1);
|
||||
if (!latest) continue;
|
||||
const currentTarget = targets.find(target => target.device.key === value.target.device.key
|
||||
&& value.target.members.some(member => member.findingDefinitionId === target.definitionId));
|
||||
const coveredCves = value.target.members.map(member => member.cve).sort();
|
||||
entries.set(value.id, {
|
||||
id: value.id,
|
||||
recordId: currentTarget?.recordId,
|
||||
occurrenceId: currentTarget?.occurrenceId ?? value.target.members[0]?.capturedOccurrences[0]?.occurrenceId ?? value.id,
|
||||
title: value.target.kind === 'device-snapshot'
|
||||
? `All current findings on ${value.target.device.displayName}`
|
||||
: `${coveredCves[0] ?? 'Finding'} on ${value.target.device.displayName}`,
|
||||
source: 'Microsoft Defender for IoT',
|
||||
state: scopedRiskAcceptanceState(value, today),
|
||||
latest,
|
||||
revisions: value.revisions,
|
||||
device: value.target.device.displayName,
|
||||
coveredCves
|
||||
});
|
||||
}
|
||||
return [...entries.values()].sort((left, right) => right.latest.recordedAt.localeCompare(left.latest.recordedAt)
|
||||
|| left.title.localeCompare(right.title));
|
||||
}
|
||||
|
|
@ -76,15 +101,16 @@ function DetailGroup({ title, details, fields }: {
|
|||
</details>;
|
||||
}
|
||||
|
||||
export function RiskRegister({ dataset, reviews, onOpenFinding }: {
|
||||
export function RiskRegister({ dataset, reviews, scopedRiskAcceptances = [], onOpenFinding }: {
|
||||
dataset: ImportedDataset;
|
||||
reviews: Record<string, Review>;
|
||||
scopedRiskAcceptances?: ScopedRiskAcceptance[];
|
||||
onOpenFinding?(recordId: string): void;
|
||||
}) {
|
||||
const entries = riskRegisterEntries(dataset, reviews);
|
||||
const entries = riskRegisterEntries(dataset, reviews, undefined, scopedRiskAcceptances);
|
||||
const [requestedId, setRequestedId] = useState('');
|
||||
const selected = entries.find(entry => entry.id === requestedId) ?? entries[0];
|
||||
const currentCount = entries.filter(entry => ['Accepted', 'Scheduled', 'Review due', 'Review required'].includes(entry.state)).length;
|
||||
const currentCount = entries.filter(entry => entry.state === 'Accepted' || entry.state === 'Review due').length;
|
||||
|
||||
return <section className="client-risk-register" aria-labelledby="client-risk-register-heading">
|
||||
<div className="client-risk-register-layout">
|
||||
|
|
@ -113,9 +139,10 @@ export function RiskRegister({ dataset, reviews, onOpenFinding }: {
|
|||
{!selected ? <p className="client-risk-register-empty">No risk acceptance has been recorded for this assessment.</p> : <article key={selected.id} className="client-risk-register-detail" aria-live="polite">
|
||||
<header>
|
||||
<div><strong>Risk Information</strong><span>{selected.source}</span></div>
|
||||
<div className="risk-register-actions">{onOpenFinding && <button className="btn primary" type="button" onClick={() => onOpenFinding(selected.recordId)}>Open finding</button>}<strong className={`is-${stateTone(selected.state)}`}>{selected.state}</strong></div>
|
||||
<div className="risk-register-actions">{onOpenFinding && selected.recordId && <button className="btn primary" type="button" onClick={() => onOpenFinding(selected.recordId!)}>Open finding</button>}<strong className={`is-${stateTone(selected.state)}`}>{selected.state}</strong></div>
|
||||
</header>
|
||||
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2></section>
|
||||
{selected.coveredCves && <section className="risk-register-scoped-target"><span>Immutable system scope</span><p>{selected.device}</p><p>{selected.coveredCves.length.toLocaleString('en-US')} covered CVE{selected.coveredCves.length === 1 ? '' : 's'}: {selected.coveredCves.join(', ')}</p></section>}
|
||||
<dl className="risk-register-summary">
|
||||
<DetailField label="Initial risk" value={selected.latest.details.initialRisk} />
|
||||
<DetailField label="Residual risk" value={selected.latest.details.residualRisk} />
|
||||
|
|
|
|||
|
|
@ -371,6 +371,8 @@ export function DatasetViewer() {
|
|||
onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)}
|
||||
onReview={patch => updateReview(selected.id, patch)}
|
||||
onRiskAcceptance={selected.findingOccurrenceId && app.backendStatus === 'connected' ? command => app.recordRiskAcceptance(selected.id, command) : undefined}
|
||||
scopedRiskAcceptances={app.scopedRiskAcceptances}
|
||||
onScopedRiskAcceptance={app.backendStatus === 'connected' ? write => app.recordScopedRiskAcceptance(write) : undefined}
|
||||
onResults={hasResults(selected) ? () => openResults(selected) : undefined}
|
||||
resultsOpen={Boolean(resultColor(selected.id))}
|
||||
resultColor={resultColor(selected.id)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@ import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
|||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot';
|
||||
import type { ImportedDataset } from '../../domain/dataset';
|
||||
import type { Review } from '../../domain/review';
|
||||
import {
|
||||
defenderRiskCoverageDecisions,
|
||||
defenderRiskTargetsForRecord,
|
||||
type DefenderRiskTarget,
|
||||
type ScopedRiskAcceptance,
|
||||
type ScopedRiskAcceptanceWrite
|
||||
} from '../../domain/scoped-risk-acceptance';
|
||||
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||||
import { RiskAcceptanceDialog } from '../finding-reader/RiskAcceptanceDialog';
|
||||
import { defenderModelForDataset } from './defender-dataset-model';
|
||||
|
||||
type DefenderView = 'cves' | 'devices' | 'sites';
|
||||
|
|
@ -29,11 +38,13 @@ function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' })
|
|||
return <span className={`defender-severity severity-${severity}`}><i aria-hidden="true" />{label(severity)}</span>;
|
||||
}
|
||||
|
||||
function DeviceCves({ device, occurrences, id }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences']; id?: string }) {
|
||||
function DeviceCves({ device, occurrences, id, coverage, coveringDecisions = [], onAcceptAll }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences']; id?: string; coverage?: string; coveringDecisions?: string[]; onAcceptAll?: () => void }) {
|
||||
const values = occurrences.filter(item => device.occurrenceIds.includes(item.id));
|
||||
return <div className="defender-drilldown" id={id}>
|
||||
<h3>{device.displayName || 'Unnamed device'}</h3>
|
||||
<p>{device.ip || 'IP unavailable'} · {device.site || 'Site unavailable'} · Identity: {device.identityProvenance} ({device.identityConfidence})</p>
|
||||
{coverage && <div className="defender-risk-coverage"><span>{coverage}</span>{onAcceptAll && <button className="btn primary" type="button" onClick={onAcceptAll}>Accept all current findings</button>}</div>}
|
||||
{coveringDecisions.length > 0 && <details className="defender-risk-decisions"><summary>Covering decisions ({coveringDecisions.length.toLocaleString('en-US')})</summary><ul>{coveringDecisions.map(item => <li key={item}>{item}</li>)}</ul></details>}
|
||||
<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>
|
||||
|
|
@ -43,12 +54,38 @@ function DeviceCves({ device, occurrences, id }: { device: DefenderDevice; occur
|
|||
</div>;
|
||||
}
|
||||
|
||||
export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: ImportedDataset; navigation?: ReactNode }) {
|
||||
export function DefenderVulnerabilityPanel({ dataset, navigation, reviews = {}, scopedRiskAcceptances = [], activeScanRunIds = [], onScopedRiskAcceptance }: {
|
||||
dataset: ImportedDataset;
|
||||
navigation?: ReactNode;
|
||||
reviews?: Record<string, Review>;
|
||||
scopedRiskAcceptances?: ScopedRiskAcceptance[];
|
||||
activeScanRunIds?: string[];
|
||||
onScopedRiskAcceptance?(write: ScopedRiskAcceptanceWrite): Promise<void>;
|
||||
}) {
|
||||
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 [bulkDeviceKey, setBulkDeviceKey] = useState('');
|
||||
const riskTargets = useMemo(() => dataset.sheets.flatMap(sheet => sheet.analysis.records.flatMap(record => defenderRiskTargetsForRecord(record, sheet.toolId))), [dataset]);
|
||||
const targetsByDevice = useMemo(() => {
|
||||
const result = new Map<string, DefenderRiskTarget[]>();
|
||||
for (const target of riskTargets) result.set(target.device.key, [...(result.get(target.device.key) ?? []), target]);
|
||||
return result;
|
||||
}, [riskTargets]);
|
||||
const uniqueTargetsForDevice = (deviceKey: string) => {
|
||||
const unique = new Map<string, DefenderRiskTarget>();
|
||||
for (const target of targetsByDevice.get(deviceKey) ?? []) if (!unique.has(target.definitionId)) unique.set(target.definitionId, target);
|
||||
return [...unique.values()];
|
||||
};
|
||||
const bulkTargets = targetsByDevice.get(bulkDeviceKey) ?? [];
|
||||
const bulkDefinitions = [...new Set(bulkTargets.map(target => target.definitionId))].sort();
|
||||
const existingBulkAcceptance = scopedRiskAcceptances
|
||||
.filter(item => item.target.kind === 'device-snapshot' && item.target.device.key === bulkDeviceKey
|
||||
&& item.target.members.length === bulkDefinitions.length
|
||||
&& item.target.members.every(member => bulkDefinitions.includes(member.findingDefinitionId)))
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
|
||||
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>;
|
||||
|
||||
|
|
@ -101,12 +138,42 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
|
|||
const detailId = `defender-device-detail-${index}`;
|
||||
return <Fragment key={device.id}>
|
||||
<tr className={expanded ? 'defender-selected-row' : undefined}><td><button className="defender-row-toggle" type="button" aria-expanded={expanded} aria-controls={detailId} onClick={() => setSelectedDevice(current => current === device.id ? '' : device.id)}><TriangleIcon direction={expanded ? 'down' : 'right'} />{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>
|
||||
{expanded && <tr className="defender-detail-row"><td colSpan={4 + severities.length}><DeviceCves device={device} occurrences={model.occurrences} id={detailId} /></td></tr>}
|
||||
{expanded && <tr className="defender-detail-row"><td colSpan={4 + severities.length}><DeviceCves device={device} occurrences={model.occurrences} id={detailId}
|
||||
coverage={(() => {
|
||||
const targets = uniqueTargetsForDevice(device.id);
|
||||
const covered = targets.filter(target => defenderRiskCoverageDecisions(target, reviews[target.recordId]?.riskAcceptance, scopedRiskAcceptances).length > 0).length;
|
||||
return targets.length ? `${covered.toLocaleString('en-US')} of ${targets.length.toLocaleString('en-US')} current findings covered by valid acceptance` : 'No actionable detailed CVE findings';
|
||||
})()}
|
||||
coveringDecisions={(() => {
|
||||
const targets = uniqueTargetsForDevice(device.id);
|
||||
const labels = new Set<string>();
|
||||
for (const target of targets) {
|
||||
for (const decision of defenderRiskCoverageDecisions(target, reviews[target.recordId]?.riskAcceptance, scopedRiskAcceptances)) {
|
||||
if (decision.kind === 'whole-finding') labels.add(`Entire finding · ${target.cve} · ${decision.state}`);
|
||||
else {
|
||||
const acceptance = scopedRiskAcceptances.find(item => item.id === decision.id)!;
|
||||
labels.add(`${decision.kind === 'device-snapshot' ? 'Device snapshot' : 'Device finding'} · ${decision.id} · ${acceptance.target.members.map(member => member.cve).join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...labels];
|
||||
})()}
|
||||
onAcceptAll={(targetsByDevice.get(device.id) ?? []).some(target => target.eligible) && onScopedRiskAcceptance ? () => setBulkDeviceKey(device.id) : undefined} /></td></tr>}
|
||||
</Fragment>;
|
||||
})}
|
||||
</tbody></table></div>
|
||||
</section>}
|
||||
|
||||
{bulkDeviceKey && bulkTargets.length > 0 && <RiskAcceptanceDialog key={`${bulkDeviceKey}:${existingBulkAcceptance?.id ?? 'new'}`}
|
||||
title={`All current findings on ${bulkTargets[0].device.displayName}`}
|
||||
value={existingBulkAcceptance}
|
||||
systemScope={`${bulkTargets[0].device.displayName}${bulkTargets[0].device.ip ? ` (${bulkTargets[0].device.ip})` : ''} · current selected Defender scan scope`}
|
||||
memberLabels={[...new Set(bulkTargets.map(target => target.cve))].sort()}
|
||||
onSave={onScopedRiskAcceptance ? command => onScopedRiskAcceptance(existingBulkAcceptance
|
||||
? { acceptanceId: existingBulkAcceptance.id, command, expectedRevision: existingBulkAcceptance.revision }
|
||||
: { target: { kind: 'device-snapshot', deviceKey: bulkDeviceKey, scanRunIds: activeScanRunIds.length ? activeScanRunIds : [...new Set(bulkTargets.map(target => target.scanRunId))] }, command, expectedRevision: 0 }) : undefined}
|
||||
onClose={() => setBulkDeviceKey('')} />}
|
||||
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import type { FindingRecord, RecordField } from '../../domain/finding';
|
|||
import type { Review } from '../../domain/review';
|
||||
import { defaultReview } from '../../domain/review';
|
||||
import { riskAcceptanceState, type RiskAcceptanceCommand } from '../../domain/risk-acceptance';
|
||||
import {
|
||||
defenderRiskTargetsForRecord,
|
||||
defenderRiskCoverageDecisions,
|
||||
type ScopedRiskAcceptance,
|
||||
type ScopedRiskAcceptanceWrite
|
||||
} from '../../domain/scoped-risk-acceptance';
|
||||
import { RiskAcceptanceDialog } from './RiskAcceptanceDialog';
|
||||
import { PolicySourceEvidence } from './PolicySourceEvidence';
|
||||
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
|
||||
|
|
@ -42,6 +48,8 @@ interface Props {
|
|||
onNext(): void;
|
||||
onReview(patch: Partial<Review>): void;
|
||||
onRiskAcceptance?(command: RiskAcceptanceCommand): Promise<void>;
|
||||
scopedRiskAcceptances?: ScopedRiskAcceptance[];
|
||||
onScopedRiskAcceptance?(write: ScopedRiskAcceptanceWrite): Promise<void>;
|
||||
onResults?(): void;
|
||||
/** Ist das Ergebnisfenster zu diesem Fund gerade geoeffnet? */
|
||||
resultsOpen?: boolean;
|
||||
|
|
@ -329,16 +337,23 @@ export function policyAnalyzerSearchKeys(record: FindingRecord, title: string, t
|
|||
return keys;
|
||||
}
|
||||
|
||||
export function FindingReader({ record, title, review: storedReview, previousPolicyReviews, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onRiskAcceptance, onResults, resultsOpen, resultColor, projectId = '', backendStatus = 'unavailable' }: Props) {
|
||||
export function FindingReader({ record, title, review: storedReview, previousPolicyReviews, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onRiskAcceptance, scopedRiskAcceptances = [], onScopedRiskAcceptance, onResults, resultsOpen, resultColor, projectId = '', backendStatus = 'unavailable' }: Props) {
|
||||
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
|
||||
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
|
||||
const [riskAcceptanceFor, setRiskAcceptanceFor] = useState('');
|
||||
const [riskScope, setRiskScope] = useState('entire');
|
||||
const linkedResultsOpen = openLinkedResultsFor === record.id;
|
||||
const review = { ...defaultReview(), ...storedReview };
|
||||
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
|
||||
const severity = normalizeSeverity(severityField?.value);
|
||||
const hasStructuredResults = linkedSheets.length > 0;
|
||||
const resolvedToolId = toolIdForRecord(record, toolId);
|
||||
const deviceRiskTargets = defenderRiskTargetsForRecord(record, resolvedToolId);
|
||||
const scopedForFinding = scopedRiskAcceptances.filter(acceptance => acceptance.target.members.some(member => member.findingDefinitionId === record.findingDefinitionId));
|
||||
const selectedDeviceTarget = deviceRiskTargets.find(target => target.device.key === riskScope);
|
||||
const selectedScopedAcceptance = selectedDeviceTarget ? scopedForFinding
|
||||
.filter(acceptance => acceptance.target.kind === 'device-finding' && acceptance.target.device.key === selectedDeviceTarget.device.key)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] : undefined;
|
||||
const webSearch = findingWebSearch(record, resolvedToolId);
|
||||
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
|
||||
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
|
||||
|
|
@ -352,6 +367,11 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
const updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
|
||||
const regulatoryMapping = useRegulatoryMapping(projectId, record.findingOccurrenceId, backendStatus);
|
||||
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
|
||||
const coveringDecisionLabels = selectedDeviceTarget
|
||||
? defenderRiskCoverageDecisions(selectedDeviceTarget, review.riskAcceptance, scopedForFinding).map(decision => decision.kind === 'whole-finding'
|
||||
? `Entire finding · ${selectedDeviceTarget.cve} · ${decision.state}`
|
||||
: `${decision.kind === 'device-snapshot' ? 'Device snapshot' : 'Device finding'} · ${decision.id}`)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<article className="finding-reader">
|
||||
|
|
@ -386,10 +406,10 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
</div>
|
||||
{mitre.length > 0 && <div className="mitre-line">{mitre.map(field => <span className="mitre-badge" key={field.label}>{mitreBadgeLabel(field.label)}: {highlightText(field.value, search)}</span>)}</div>}
|
||||
</div>
|
||||
<button className={`btn accepted-risk-trigger${riskState === 'Draft' ? ' is-draft' : ''}`} data-risk-acceptance-state={riskState?.toLowerCase() ?? 'not-recorded'} type="button" aria-haspopup="dialog" onClick={() => setRiskAcceptanceFor(record.id)}>
|
||||
<button className={`btn accepted-risk-trigger${riskState === 'Draft' ? ' is-draft' : ''}`} data-risk-acceptance-state={riskState?.toLowerCase() ?? 'not-recorded'} type="button" aria-haspopup="dialog" onClick={() => { setRiskScope('entire'); setRiskAcceptanceFor(record.id); }}>
|
||||
Accepted Risk{riskState
|
||||
? <small>{riskState}</small>
|
||||
: ' ?'}
|
||||
: scopedForFinding.length ? <small>{scopedForFinding.length} scoped</small> : ' ?'}
|
||||
</button>
|
||||
<div className="reader-heading-actions">
|
||||
{policySearchKeys.length > 0 && <div className="policy-search-key"><span>Policy Analyzer search {policySearchKeys.length === 1 ? 'key' : 'terms'}</span><div>{policySearchKeys.map(key => <span className="policy-search-key-token" key={key}>{highlightText(key, search)}</span>)}</div></div>}
|
||||
|
|
@ -404,7 +424,20 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
<button className="bookmark-btn" type="button" aria-pressed={review.include} onClick={() => onReview({ include: !review.include })}><BookmarkIcon filled={review.include} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{riskAcceptanceFor === record.id && <RiskAcceptanceDialog key={record.id} title={title} occurrenceId={record.findingOccurrenceId} value={review.riskAcceptance} onSave={onRiskAcceptance} onClose={() => setRiskAcceptanceFor('')} />}
|
||||
{riskAcceptanceFor === record.id && <RiskAcceptanceDialog key={`${record.id}:${riskScope}`} title={title} occurrenceId={record.findingOccurrenceId}
|
||||
value={riskScope === 'entire' ? review.riskAcceptance : selectedScopedAcceptance}
|
||||
systemScope={selectedDeviceTarget ? `${selectedDeviceTarget.cve} on ${selectedDeviceTarget.device.displayName}${selectedDeviceTarget.device.ip ? ` (${selectedDeviceTarget.device.ip})` : ''}` : undefined}
|
||||
memberLabels={selectedDeviceTarget ? [selectedDeviceTarget.cve] : []}
|
||||
coveringDecisionLabels={coveringDecisionLabels}
|
||||
scopeOptions={deviceRiskTargets.length ? [
|
||||
{ value: 'entire', label: 'Entire finding', help: 'Applies to every affected device for this CVE.' },
|
||||
...deviceRiskTargets.map(target => ({ value: target.device.key, label: target.device.displayName, disabled: !target.eligible, help: target.unavailableReason || `${target.device.identityMethod} identity · ${target.device.identityConfidence} confidence` }))
|
||||
] : []}
|
||||
selectedScope={riskScope} onScopeChange={setRiskScope}
|
||||
onSave={riskScope === 'entire' ? onRiskAcceptance : selectedDeviceTarget && onScopedRiskAcceptance ? command => onScopedRiskAcceptance(selectedScopedAcceptance
|
||||
? { acceptanceId: selectedScopedAcceptance.id, command, expectedRevision: selectedScopedAcceptance.revision }
|
||||
: { target: { kind: 'device-finding', occurrenceId: selectedDeviceTarget.occurrenceId, deviceKey: selectedDeviceTarget.device.key }, command, expectedRevision: 0 }) : undefined}
|
||||
onClose={() => setRiskAcceptanceFor('')} />}
|
||||
<section className="dashboard-grid">
|
||||
<label>Status<select value={review.status} onChange={updateText('status')}>{statuses.map(value => <option key={value}>{value}</option>)}</select></label>
|
||||
<label>Priority<select value={review.priority} onChange={updateText('priority')}>{priorities.map(value => <option key={value}>{value}</option>)}</select></label>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import { Dialog } from '../../components/Dialog/Dialog';
|
||||
import { emptyRiskAcceptanceDetails, riskAcceptanceIssues, riskAcceptanceState, type RiskAcceptance,
|
||||
type RiskAcceptanceAction, type RiskAcceptanceCommand, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
|
||||
import { scopedRiskAcceptanceState, type ScopedRiskAcceptance } from '../../domain/scoped-risk-acceptance';
|
||||
|
||||
type Field = { key: keyof RiskAcceptanceDetails; label: string; kind?: 'date' | 'risk' | 'short'; help?: string };
|
||||
const groups: Array<{ title: string; fields: Field[] }> = [
|
||||
{ title: 'Scope and business decision', fields: [
|
||||
{ key: 'scope', label: 'Affected assets, systems and exclusions', help: 'Define exactly where this exception applies. This record is linked to the current finding occurrence.' },
|
||||
{ key: 'scope', label: 'Affected assets, systems and exclusions', help: 'Describe the business boundaries and exclusions. The immutable technical target is shown separately when available.' },
|
||||
{ key: 'scenario', label: 'Risk scenario', help: 'Threat, weakness, possible event and business consequence.' },
|
||||
{ key: 'businessReason', label: 'Reason for accepting the risk' },
|
||||
{ key: 'alternatives', label: 'Alternatives considered', help: 'Document mitigation, avoidance and transfer options, their costs and why they were not chosen.' },
|
||||
|
|
@ -46,7 +47,13 @@ const groups: Array<{ title: string; fields: Field[] }> = [
|
|||
interface Props {
|
||||
title: string;
|
||||
occurrenceId?: string;
|
||||
value?: RiskAcceptance;
|
||||
value?: RiskAcceptance | ScopedRiskAcceptance;
|
||||
systemScope?: string;
|
||||
memberLabels?: string[];
|
||||
coveringDecisionLabels?: string[];
|
||||
scopeOptions?: Array<{ value: string; label: string; disabled?: boolean; help?: string }>;
|
||||
selectedScope?: string;
|
||||
onScopeChange?(value: string): void;
|
||||
onSave?: (command: RiskAcceptanceCommand) => Promise<void>;
|
||||
onClose(): void;
|
||||
}
|
||||
|
|
@ -55,7 +62,7 @@ const actionLabels: Record<RiskAcceptanceAction, string> = {
|
|||
accept: 'Record acceptance',
|
||||
revoke: 'Revoke acceptance'
|
||||
};
|
||||
export function RiskAcceptanceDialog({ title, occurrenceId, value, onSave, onClose }: Props) {
|
||||
export function RiskAcceptanceDialog({ title, occurrenceId, value, systemScope, memberLabels = [], coveringDecisionLabels = [], scopeOptions = [], selectedScope, onScopeChange, onSave, onClose }: Props) {
|
||||
const latest = value?.revisions.at(-1);
|
||||
const [details, setDetails] = useState<RiskAcceptanceDetails>(() => ({ ...emptyRiskAcceptanceDetails(), ...latest?.details }));
|
||||
const [recordedBy, setRecordedBy] = useState('');
|
||||
|
|
@ -138,7 +145,14 @@ export function RiskAcceptanceDialog({ title, occurrenceId, value, onSave, onClo
|
|||
</div>
|
||||
</section> : <form ref={form} className="risk-acceptance-form" onSubmit={event => { event.preventDefault(); review('draft'); }}>
|
||||
<p className="risk-acceptance-context">{title}</p>
|
||||
<p>Decision state: <strong>{riskAcceptanceState(value, occurrenceId)}</strong>. Technical findings remain unchanged.</p>
|
||||
{scopeOptions.length > 0 && <label className="risk-acceptance-scope-select"><span>Decision scope</span><select value={selectedScope} onChange={event => onScopeChange?.(event.target.value)}>
|
||||
{scopeOptions.map(option => <option key={option.value} value={option.value} disabled={option.disabled}>{option.label}</option>)}
|
||||
</select>{scopeOptions.find(option => option.value === selectedScope)?.help && <small>{scopeOptions.find(option => option.value === selectedScope)!.help}</small>}</label>}
|
||||
<p>Decision state: <strong>{value && 'target' in value ? scopedRiskAcceptanceState(value) : riskAcceptanceState(value, occurrenceId)}</strong>. Technical findings remain unchanged.</p>
|
||||
{systemScope && <section className="risk-acceptance-system-scope" aria-label="Immutable decision scope"><h3>System scope</h3><p>{systemScope}</p>
|
||||
{memberLabels.length > 0 && <><strong>{memberLabels.length.toLocaleString('en-US')} covered CVE{memberLabels.length === 1 ? '' : 's'}</strong><ul>{memberLabels.map(label => <li key={label}>{label}</li>)}</ul></>}
|
||||
{coveringDecisionLabels.length > 0 && <><strong>Currently covering decisions</strong><ul>{coveringDecisionLabels.map(label => <li key={label}>{label}</li>)}</ul></>}
|
||||
</section>}
|
||||
<p className="risk-acceptance-note">Record an external approval here. Names and references are documented, not authenticated signatures. Complete all fields except optional evidence before recording acceptance.</p>
|
||||
{latest?.action === 'accept' && <p>A new draft replaces the active decision state until acceptance is recorded again. Earlier approvals remain in the history.</p>}
|
||||
{!onSave && <p role="status">Open a saved finding with a connected backend to save this record.</p>}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from '../domain/canonical-model';
|
||||
import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
|
||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||
import type { ScopedRiskAcceptance, ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance';
|
||||
import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison';
|
||||
import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow';
|
||||
import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence';
|
||||
|
|
@ -134,6 +135,7 @@ export interface ProjectDataExport {
|
|||
cveEnrichments: CveEnrichmentRecord[];
|
||||
evidence: EvidenceRecord[];
|
||||
reviews: FindingReview[];
|
||||
scopedRiskAcceptances: ScopedRiskAcceptance[];
|
||||
diagnostics: ImportDiagnostic[];
|
||||
comparisons: StoredComparison[];
|
||||
localComparisonRules: LocalComparisonRule[];
|
||||
|
|
@ -266,6 +268,8 @@ export interface AssessorRepository {
|
|||
|
||||
saveReview(review: FindingReview, expectedRevision?: number): Promise<void>;
|
||||
recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<FindingReview>;
|
||||
recordScopedRiskAcceptance(projectId: string, write: ScopedRiskAcceptanceWrite): Promise<ScopedRiskAcceptance>;
|
||||
listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]>;
|
||||
listReviews(projectId: string): Promise<FindingReview[]>;
|
||||
|
||||
saveProfileRegistration(profile: ProfileRegistration): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||
import {
|
||||
appendScopedRiskAcceptance,
|
||||
scopedRiskTargetFromOccurrences,
|
||||
type ScopedRiskAcceptance,
|
||||
type ScopedRiskAcceptanceTarget,
|
||||
type ScopedRiskAcceptanceWrite
|
||||
} from '../domain/scoped-risk-acceptance';
|
||||
import type {
|
||||
Assessment,
|
||||
AssessmentProject,
|
||||
|
|
@ -106,6 +113,7 @@ interface LocalRepositoryState {
|
|||
regulatoryMappingDecisions: RegulatoryMappingDecision[];
|
||||
evidence: EvidenceRecord[];
|
||||
reviews: FindingReview[];
|
||||
scopedRiskAcceptances: ScopedRiskAcceptance[];
|
||||
diagnostics: ImportDiagnostic[];
|
||||
profileRegistrations: ProfileRegistration[];
|
||||
comparisons: StoredComparison[];
|
||||
|
|
@ -143,6 +151,7 @@ const emptyState = (): LocalRepositoryState => ({
|
|||
regulatoryMappingDecisions: [],
|
||||
evidence: [],
|
||||
reviews: [],
|
||||
scopedRiskAcceptances: [],
|
||||
diagnostics: [],
|
||||
profileRegistrations: [],
|
||||
comparisons: [],
|
||||
|
|
@ -1471,6 +1480,55 @@ export class LocalAssessorRepository implements AssessorRepository {
|
|||
return clone(review);
|
||||
}
|
||||
|
||||
async recordScopedRiskAcceptance(projectId: string, write: ScopedRiskAcceptanceWrite): Promise<ScopedRiskAcceptance> {
|
||||
if (!this.state.projects.some(item => item.id === projectId)) throw new RepositoryNotFoundError('Project is not available.');
|
||||
const next = clone(this.state);
|
||||
const current = write.acceptanceId
|
||||
? next.scopedRiskAcceptances.find(item => item.id === write.acceptanceId && item.projectId === projectId)
|
||||
: undefined;
|
||||
if (write.acceptanceId && !current) throw new RepositoryNotFoundError('Scoped risk acceptance is not available.');
|
||||
if (!Number.isSafeInteger(write.expectedRevision) || write.expectedRevision !== (current?.revision ?? 0)) {
|
||||
throw new RepositoryConflictError('The scoped risk acceptance changed. Reload it before recording a decision.');
|
||||
}
|
||||
let target: ScopedRiskAcceptanceTarget;
|
||||
if (current) {
|
||||
if (write.target) throw new RepositoryValidationError('An existing scoped risk acceptance cannot be retargeted.');
|
||||
target = current.target;
|
||||
} else {
|
||||
if (!write.target) throw new RepositoryValidationError('A scoped risk target is required.');
|
||||
const requestedTarget = write.target;
|
||||
const assessmentIds = new Set(next.assessments.filter(item => item.projectId === projectId).map(item => item.id));
|
||||
const projectRunIds = new Set(next.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id));
|
||||
const selectedOccurrence = requestedTarget.kind === 'device-finding'
|
||||
? next.findingOccurrences.find(item => item.id === requestedTarget.occurrenceId && projectRunIds.has(item.scanRunId))
|
||||
: undefined;
|
||||
const occurrences = requestedTarget.kind === 'device-finding'
|
||||
? next.findingOccurrences.filter(item => item.scanRunId === selectedOccurrence?.scanRunId && projectRunIds.has(item.scanRunId))
|
||||
: next.findingOccurrences.filter(item => requestedTarget.scanRunIds.includes(item.scanRunId) && projectRunIds.has(item.scanRunId));
|
||||
if (requestedTarget.kind === 'device-snapshot' && (!requestedTarget.scanRunIds.length || requestedTarget.scanRunIds.some(id => !projectRunIds.has(id)))) {
|
||||
throw new RepositoryValidationError('The selected scan-run scope is not valid for this project.');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
try { target = scopedRiskTargetFromOccurrences(requestedTarget.kind, requestedTarget.deviceKey, occurrences, now,
|
||||
selectedOccurrence ? new Set([selectedOccurrence.id]) : undefined); }
|
||||
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Invalid scoped risk target.'); }
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const id = current?.id ?? `scoped-risk-${globalThis.crypto.randomUUID()}`;
|
||||
let saved: ScopedRiskAcceptance;
|
||||
try { saved = appendScopedRiskAcceptance(current, projectId, id, target, write.command, now); }
|
||||
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Invalid scoped risk acceptance.'); }
|
||||
const index = next.scopedRiskAcceptances.findIndex(item => item.id === saved.id);
|
||||
if (index >= 0) next.scopedRiskAcceptances[index] = saved; else next.scopedRiskAcceptances.push(saved);
|
||||
this.replaceState(next);
|
||||
return clone(saved);
|
||||
}
|
||||
|
||||
async listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]> {
|
||||
if (!this.state.projects.some(item => item.id === projectId)) throw new RepositoryNotFoundError('Project is not available.');
|
||||
return this.state.scopedRiskAcceptances.filter(item => item.projectId === projectId).map(clone);
|
||||
}
|
||||
|
||||
async listReviews(projectId: string): Promise<FindingReview[]> {
|
||||
const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id));
|
||||
const runIds = new Set(this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id));
|
||||
|
|
@ -1579,6 +1637,7 @@ export class LocalAssessorRepository implements AssessorRepository {
|
|||
cveEnrichments: this.state.cveEnrichments.filter(item => cveIds.has(item.cveId)).map(clone),
|
||||
evidence: byId(this.state.evidence.filter(item => occurrenceIds.has(item.occurrenceId))),
|
||||
reviews: await this.listReviews(projectId),
|
||||
scopedRiskAcceptances: await this.listScopedRiskAcceptances(projectId),
|
||||
diagnostics: byId(this.state.diagnostics.filter(item => fileIds.has(item.importFileId))),
|
||||
comparisons: byId(this.state.comparisons.filter(item => item.projectId === projectId)),
|
||||
localComparisonRules: this.state.localComparisonRules.filter(item => item.projectId === projectId).map(item => item.rule).sort((left, right) => left.id.localeCompare(right.id)),
|
||||
|
|
@ -1781,6 +1840,7 @@ export class LocalAssessorRepository implements AssessorRepository {
|
|||
next.policyEvaluations = next.policyEvaluations.filter(item => item.run.projectId !== projectId);
|
||||
next.aiOverviewRuns = next.aiOverviewRuns.filter(item => item.projectId !== projectId);
|
||||
next.regulatoryMappingBatchRuns = next.regulatoryMappingBatchRuns.filter(item => item.projectId !== projectId);
|
||||
next.scopedRiskAcceptances = next.scopedRiskAcceptances.filter(item => item.projectId !== projectId);
|
||||
// Regulatory mappings and decisions are global and survive project deletion.
|
||||
this.replaceState(next);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,14 @@ mark, .search-highlight {
|
|||
.risk-acceptance-form { display: grid; gap: 16px; }
|
||||
.risk-acceptance-form p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }
|
||||
.risk-acceptance-context { font-weight: var(--fw-semibold); }
|
||||
.risk-acceptance-scope-select { display: grid; max-width: 480px; gap: var(--space-1); }
|
||||
.risk-acceptance-scope-select > span, .risk-acceptance-system-scope h3 { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
|
||||
.risk-acceptance-scope-select select { width: 100%; min-width: 0; height: var(--btn-h); padding: 0 var(--btn-pad-x); border: var(--bw-hairline) solid var(--line-strong); border-radius: var(--btn-radius); color: var(--text); background: var(--surface-input); font: inherit; }
|
||||
.risk-acceptance-scope-select small { color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.risk-acceptance-system-scope { display: grid; gap: var(--space-2); padding: var(--space-3) 0; border-top: var(--bw-hairline) solid var(--line); border-bottom: var(--bw-hairline) solid var(--line); }
|
||||
.risk-acceptance-system-scope h3, .risk-acceptance-system-scope p, .risk-acceptance-system-scope ul { margin: 0; }
|
||||
.risk-acceptance-system-scope ul { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: 0; list-style: none; }
|
||||
.risk-acceptance-system-scope li { padding: var(--space-1) var(--space-2); border: var(--bw-hairline) solid var(--line-strong); border-radius: var(--radius-xs); font-size: var(--type-table-body-size); }
|
||||
.risk-acceptance-note { color: var(--muted); }
|
||||
.risk-acceptance-fields { min-width: 0; display: grid; gap: 24px; margin: 0; padding: 0; border: 0; }
|
||||
.risk-acceptance-fields h3, .risk-acceptance-history h3 { margin: 0 0 12px; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||
|
|
|
|||
|
|
@ -634,6 +634,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
|||
.client-risk-register-detail > header span,
|
||||
.risk-register-title > span { color: var(--client-info-muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); }
|
||||
.client-risk-register-detail > header > .risk-register-actions { display: flex; flex: 0 0 auto; flex-direction: row; align-items: center; gap: var(--space-2); }
|
||||
.risk-register-scoped-target { padding: var(--space-4) 0; border-bottom: var(--bw-hairline) solid var(--border-default); }
|
||||
.risk-register-scoped-target > span { color: var(--client-info-muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); }
|
||||
.risk-register-scoped-target p { margin: var(--space-2) 0 0; color: var(--client-info-text); font-size: var(--type-table-body-size); line-height: 1.5; }
|
||||
.risk-register-actions > strong { --risk-register-tone: var(--client-info-neutral); padding: var(--space-1) var(--space-2); border-left: var(--marker-width) solid var(--risk-register-tone); color: var(--risk-register-tone); background: var(--client-info-surface); font-size: var(--type-badge-size); font-weight: var(--fw-semibold); text-transform: uppercase; }
|
||||
.risk-register-title { display: grid; gap: var(--space-2); padding: var(--space-5) 0; border-bottom: var(--bw-hairline) solid var(--border-default); }
|
||||
.risk-register-title h2 { margin: 0; color: var(--client-info-text); font-size: var(--type-detail-title-size); font-weight: var(--fw-semibold); line-height: 1.35; overflow-wrap: break-word; }
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@
|
|||
.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-risk-coverage { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) 0; border-top: var(--bw-hairline) solid var(--line); border-bottom: var(--bw-hairline) solid var(--line); }
|
||||
.defender-risk-coverage > span { color: var(--text); font-size: var(--type-table-body-size); }
|
||||
.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); }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { FindingOccurrence, JsonValue } from '../../src/domain/canonical-model';
|
||||
import {
|
||||
appendScopedRiskAcceptance,
|
||||
defenderRiskCoverageDecisions,
|
||||
defenderRiskTargetsForOccurrence,
|
||||
defenderRiskTargetsForOccurrences,
|
||||
scopedAcceptanceCovers,
|
||||
scopedRiskAcceptanceState,
|
||||
scopedRiskTargetFromOccurrences
|
||||
} from '../../src/domain/scoped-risk-acceptance';
|
||||
import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance';
|
||||
import { riskCommand } from './risk-acceptance-fixture';
|
||||
|
||||
const now = '2026-09-21T08:00:00.000Z';
|
||||
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
|
||||
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' };
|
||||
|
||||
function observation(deviceId: string, cve: string, name = deviceId, ip = '192.0.2.10') {
|
||||
return {
|
||||
name, ip, site: 'Synthetic site', sensor: 'sensor-a', cve, severity: 'High', description: 'Synthetic finding',
|
||||
additionalSourceFields: deviceId ? { 'Device ID': deviceId } : {}, source: { tableIndex: 1, rowIndex: 2 }
|
||||
};
|
||||
}
|
||||
|
||||
function occurrence(id: string, definitionId: string, cve: string, observations: ReturnType<typeof observation>[], scanRunId = 'run-1'): FindingOccurrence {
|
||||
return {
|
||||
id, definitionId, scanRunId, importFileId: `file-${scanRunId}`, sourceTableId: `table-${scanRunId}`,
|
||||
sourceIdentity: {
|
||||
tool: { id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', version: '1.2.0' },
|
||||
originalRuleId: cve, canonicalRuleId: cve, originalTitle: cve
|
||||
},
|
||||
sourcePosition: { rowIndex: 2 },
|
||||
originalFields: [{ key: 'source-observations', label: 'Source Observations', value: observations as JsonValue, columnIndex: 0 }],
|
||||
fieldMappings: [],
|
||||
severity: { originalValue: 'High', normalizedValue: 'high', provenance },
|
||||
status: { originalValue: 'Open', normalizedValue: 'open', provenance }
|
||||
};
|
||||
}
|
||||
|
||||
describe('device-scoped Defender risk acceptance', () => {
|
||||
it('projects stable targets and isolates one CVE across multiple devices', () => {
|
||||
const source = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [
|
||||
observation('DEV-A', 'CVE-2026-1001', 'Device A', '192.0.2.10'),
|
||||
observation('DEV-B', 'CVE-2026-1001', 'Device B', '192.0.2.20')
|
||||
]);
|
||||
|
||||
const projected = defenderRiskTargetsForOccurrence(source);
|
||||
expect(projected).toEqual([
|
||||
expect.objectContaining({ definitionId: 'definition-cve-1', cve: 'CVE-2026-1001', device: expect.objectContaining({ key: 'device-id:dev-a', identityMethod: 'device-id', identityConfidence: 'high' }), eligible: true }),
|
||||
expect.objectContaining({ definitionId: 'definition-cve-1', cve: 'CVE-2026-1001', device: expect.objectContaining({ key: 'device-id:dev-b' }), eligible: true })
|
||||
]);
|
||||
const target = scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-a', [source], now);
|
||||
expect(target.members).toEqual([expect.objectContaining({
|
||||
findingDefinitionId: 'definition-cve-1', cve: 'CVE-2026-1001',
|
||||
capturedOccurrences: [expect.objectContaining({ occurrenceId: 'occurrence-1', scanRunId: 'run-1', importFileId: 'file-run-1', sourceTableId: 'table-run-1', sourcePosition: { rowIndex: 2 } })]
|
||||
})]);
|
||||
const pair = appendScopedRiskAcceptance(undefined, 'project-1', 'pair-1', target, riskCommand('accept', '2026-09-21'), now);
|
||||
expect(defenderRiskCoverageDecisions(projected[0], undefined, [pair], '2026-09-21')).toHaveLength(1);
|
||||
expect(defenderRiskCoverageDecisions(projected[1], undefined, [pair], '2026-09-21')).toHaveLength(0);
|
||||
const whole = appendRiskAcceptanceReview(undefined, source.id, riskCommand('accept', '2026-09-21'), now).riskAcceptance;
|
||||
expect(defenderRiskCoverageDecisions(projected[0], whole, [], '2026-09-21')).toHaveLength(1);
|
||||
expect(defenderRiskCoverageDecisions(projected[1], whole, [], '2026-09-21')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('captures every current CVE once and rejects unresolved or ambiguous device identities', () => {
|
||||
const cve1 = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001', 'Device A')]);
|
||||
const cve2 = occurrence('occurrence-2', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002', 'Device A')]);
|
||||
const duplicate = occurrence('occurrence-3', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001', 'Device A')], 'run-2');
|
||||
const target = scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-a', [cve1, cve2, duplicate], now);
|
||||
expect(target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
||||
expect(target.members[0].capturedOccurrences.map(item => item.occurrenceId)).toEqual(['occurrence-1', 'occurrence-3']);
|
||||
expect(target.capturedScanRunIds).toEqual(['run-1', 'run-2']);
|
||||
|
||||
const unresolvedObservation = { ...observation('', 'CVE-2026-2001', '', ''), site: '' };
|
||||
const unresolved = occurrence('unresolved', 'definition-unresolved', 'CVE-2026-2001', [unresolvedObservation]);
|
||||
const unresolvedTarget = defenderRiskTargetsForOccurrence(unresolved)[0];
|
||||
expect(unresolvedTarget).toMatchObject({ eligible: false, unavailableReason: expect.stringContaining('stable device identity') });
|
||||
expect(() => scopedRiskTargetFromOccurrences('device-finding', unresolvedTarget.device.key, [unresolved], now)).toThrow('stable device identity');
|
||||
|
||||
const ambiguous = occurrence('ambiguous', 'definition-ambiguous', 'CVE-2026-2002', [
|
||||
observation('DEV-X', 'CVE-2026-2002', 'Device X', '192.0.2.30'),
|
||||
observation('DEV-X', 'CVE-2026-2002', 'Different device', '192.0.2.31')
|
||||
]);
|
||||
expect(defenderRiskTargetsForOccurrence(ambiguous)[0]).toMatchObject({ eligible: false, unavailableReason: expect.stringContaining('conflicting identity') });
|
||||
expect(() => scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-x', [ambiguous], now)).toThrow('conflicting identity');
|
||||
|
||||
const crossCveConflict = [
|
||||
occurrence('cross-1', 'cross-definition-1', 'CVE-2026-3001', [observation('DEV-Y', 'CVE-2026-3001', 'Device Y', '192.0.2.40')]),
|
||||
occurrence('cross-2', 'cross-definition-2', 'CVE-2026-3002', [observation('DEV-Y', 'CVE-2026-3002', 'Different Y', '192.0.2.41')])
|
||||
];
|
||||
expect(defenderRiskTargetsForOccurrences(crossCveConflict).every(item => !item.eligible)).toBe(true);
|
||||
expect(() => scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-y', crossCveConflict, now)).toThrow('conflicting identity');
|
||||
});
|
||||
|
||||
it('derives decision state, preserves overlaps, and prevents target mutation', () => {
|
||||
const cve1 = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
|
||||
const cve2 = occurrence('occurrence-2', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002')]);
|
||||
const pairTarget = scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-a', [cve1], now);
|
||||
const snapshotTarget = scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-a', [cve1, cve2], now);
|
||||
const pair = appendScopedRiskAcceptance(undefined, 'project-1', 'pair-1', pairTarget, riskCommand('accept', '2026-09-21'), now);
|
||||
const snapshot = appendScopedRiskAcceptance(undefined, 'project-1', 'snapshot-1', snapshotTarget, riskCommand('accept', '2026-09-21'), now);
|
||||
const target1 = defenderRiskTargetsForOccurrence(cve1)[0];
|
||||
const target2 = defenderRiskTargetsForOccurrence(cve2)[0];
|
||||
|
||||
expect(scopedRiskAcceptanceState(pair, '2026-09-21')).toBe('Accepted');
|
||||
const draft = appendScopedRiskAcceptance(undefined, 'project-1', 'draft-1', pairTarget, riskCommand('draft', '2026-09-21'), now);
|
||||
expect(scopedRiskAcceptanceState(draft, '2026-09-21')).toBe('Draft');
|
||||
const scheduledCommand = riskCommand('accept', '2026-09-21');
|
||||
scheduledCommand.details.validFrom = '2026-09-22';
|
||||
const scheduled = appendScopedRiskAcceptance(undefined, 'project-1', 'scheduled-1', pairTarget, scheduledCommand, now);
|
||||
expect(scopedRiskAcceptanceState(scheduled, '2026-09-21')).toBe('Scheduled');
|
||||
const reviewDue = structuredClone(pair);
|
||||
reviewDue.revisions[0].details.reviewOn = '2026-09-21';
|
||||
expect(scopedRiskAcceptanceState(reviewDue, '2026-09-21')).toBe('Review due');
|
||||
const expired = structuredClone(pair);
|
||||
expired.revisions[0].details.expiresOn = '2026-09-20';
|
||||
expect(scopedRiskAcceptanceState(expired, '2026-09-21')).toBe('Expired');
|
||||
expect(scopedAcceptanceCovers(pair, target1, '2026-09-21')).toBe(true);
|
||||
expect(scopedAcceptanceCovers(pair, target2, '2026-09-21')).toBe(false);
|
||||
expect(scopedAcceptanceCovers(snapshot, target1, '2026-09-21')).toBe(true);
|
||||
expect(scopedAcceptanceCovers(snapshot, target2, '2026-09-21')).toBe(true);
|
||||
|
||||
const revokedPair = appendScopedRiskAcceptance(pair, 'project-1', pair.id, pair.target, riskCommand('revoke', '2026-09-21'), '2026-09-21T09:00:00.000Z');
|
||||
expect(scopedAcceptanceCovers(revokedPair, target1, '2026-09-21')).toBe(false);
|
||||
expect(scopedAcceptanceCovers(snapshot, target1, '2026-09-21')).toBe(true);
|
||||
expect(() => appendScopedRiskAcceptance(pair, 'project-1', pair.id, snapshotTarget, riskCommand('draft', '2026-09-21'), now)).toThrow('cannot be retargeted');
|
||||
});
|
||||
|
||||
it('carries coverage only to the exact device key and captured canonical CVE definition', () => {
|
||||
const original = occurrence('occurrence-old', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
|
||||
const target = scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-a', [original], now);
|
||||
const acceptance = appendScopedRiskAcceptance(undefined, 'project-1', 'snapshot-1', target, riskCommand('accept', '2026-09-21'), now);
|
||||
const laterSame = defenderRiskTargetsForOccurrence(occurrence('occurrence-new', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')], 'run-2'))[0];
|
||||
const laterNewCve = defenderRiskTargetsForOccurrence(occurrence('occurrence-new-cve', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002')], 'run-2'))[0];
|
||||
const changedDevice = defenderRiskTargetsForOccurrence(occurrence('occurrence-changed-device', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-B', 'CVE-2026-1001')], 'run-2'))[0];
|
||||
|
||||
expect(scopedAcceptanceCovers(acceptance, laterSame, '2026-09-21')).toBe(true);
|
||||
expect(scopedAcceptanceCovers(acceptance, laterNewCve, '2026-09-21')).toBe(false);
|
||||
expect(scopedAcceptanceCovers(acceptance, changedDevice, '2026-09-21')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -20,7 +20,8 @@ describe('dashboard reference layout', () => {
|
|||
expect(component).toContain('Domain Information');
|
||||
expect(component).toContain('Assessment Gaps');
|
||||
expect(component).toContain('Source Overview');
|
||||
expect(component).toContain('<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />');
|
||||
expect(component).toContain('<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances}');
|
||||
expect(component).toContain('onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />');
|
||||
expect(styles).toMatch(/\.dashboard-page\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||
expect(component).toContain('className={`client-posture tone-${overallTone}`}');
|
||||
expect(styles).toMatch(/\.dashboard-page\.client-command-center\s*\{[^}]*--client-info-surface-raised:\s*var\(--tile-surface\);[^}]*background:\s*var\(--surface-app\);/s);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ describe('Defender vulnerability dashboard projection', () => {
|
|||
expect(component).toContain('<span>Source:</span><strong>Microsoft Defender for IoT</strong>');
|
||||
expect(dashboard).toContain("['overview', 'Overview']");
|
||||
expect(dashboard).toContain("['defender', 'Microsoft Defender for IoT']");
|
||||
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />');
|
||||
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}');
|
||||
expect(dashboard).toContain('scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}');
|
||||
expect(dashboard).toContain('onScopedRiskAcceptance={app.backendStatus === \'connected\' ? write => app.recordScopedRiskAcceptance(write) : undefined}');
|
||||
expect(styles).toContain('padding: var(--panel-inset)');
|
||||
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
|
||||
expect(styles).toContain('background: var(--tile-surface)');
|
||||
|
|
|
|||
|
|
@ -27,11 +27,17 @@ function dataset(): ImportedDataset {
|
|||
['CVE-2026-1002', 'ip:192.0.2.2', 'device-b', '192.0.2.2', 'Site B', 'sensor-b', 'High', 'Synthetic description B', '1', JSON.stringify([observationB])],
|
||||
['CVE-2026-1001', 'ip:192.0.2.2', 'device-b', '192.0.2.2', 'Site B', 'sensor-b', 'Critical', 'Synthetic description A', '1', JSON.stringify([observationC])]
|
||||
];
|
||||
const vulnerabilitySheet = makeSheet('Vulnerabilities', detail, rowsToCsv(detail), 'synthetic.xlsx', { sourceTableIndex: 1, sourceSheet: 'Vulnerabilities', toolId: 'defender-for-iot-vulnerabilities' });
|
||||
vulnerabilitySheet.analysis.records.forEach((record, index) => Object.assign(record, {
|
||||
findingOccurrenceId: `occurrence-${index + 1}`,
|
||||
findingDefinitionId: index === 2 ? 'definition-1' : `definition-${index + 1}`,
|
||||
scanRunId: 'run-1', importFileId: 'file-1'
|
||||
}));
|
||||
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' }),
|
||||
vulnerabilitySheet,
|
||||
makeSheet('Assets', summary, rowsToCsv(summary), 'synthetic.xlsx', { sourceTableIndex: 0, sourceSheet: 'Assets', toolId: 'defender-for-iot-vulnerabilities' })
|
||||
]
|
||||
};
|
||||
|
|
@ -96,4 +102,29 @@ describe('Defender inline drilldowns', () => {
|
|||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it('shows exact device coverage and previews each current CVE once before grouped acceptance', () => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
act(() => root.render(<DefenderVulnerabilityPanel dataset={dataset()} activeScanRunIds={['run-1']} onScopedRiskAcceptance={vi.fn()} />));
|
||||
const devicesTab = [...container.querySelectorAll<HTMLButtonElement>('.defender-tabs button')].find(button => button.textContent === 'Devices')!;
|
||||
act(() => devicesTab.click());
|
||||
const deviceButtons = toggleButtons(container);
|
||||
act(() => deviceButtons[1].click());
|
||||
const detail = container.querySelector('#defender-device-detail-1')!;
|
||||
expect(detail.querySelector('.defender-risk-coverage')?.textContent).toContain('0 of 2 current findings covered');
|
||||
expect([...detail.querySelectorAll('.defender-table thead th')].map(header => header.textContent)).toEqual(['CVE', 'Severity']);
|
||||
const acceptAll = [...detail.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Accept all current findings')!;
|
||||
act(() => acceptAll.click());
|
||||
const scope = document.querySelector('.risk-acceptance-system-scope')!;
|
||||
expect(scope.textContent).toContain('2 covered CVEs');
|
||||
expect([...scope.querySelectorAll('li')].map(item => item.textContent)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,32 @@ async function click(label: string) {
|
|||
await act(async () => button.click());
|
||||
}
|
||||
describe('Accepted Risk dialog', () => {
|
||||
it('offers whole-finding and exact Defender device scopes without mixing system scope into the narrative', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const container = document.createElement('div'); document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const observations = [
|
||||
{ name: 'Device A', ip: '192.0.2.10', site: 'Synthetic site', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'High', description: 'Synthetic', additionalSourceFields: { 'Device ID': 'DEV-A' }, source: { tableIndex: 0, rowIndex: 1 } },
|
||||
{ name: 'Device B', ip: '192.0.2.20', site: 'Synthetic site', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'High', description: 'Synthetic', additionalSourceFields: { 'Device ID': 'DEV-B' }, source: { tableIndex: 0, rowIndex: 2 } }
|
||||
];
|
||||
const record = makeSheet('Synthetic', [['CVE', 'Source Observations'], ['CVE-2026-1001', JSON.stringify(observations)]], '', 'defender.csv', { toolId: 'defender-for-iot-vulnerabilities' }).analysis.records[0];
|
||||
Object.assign(record, { findingOccurrenceId: 'occurrence-1', findingDefinitionId: 'definition-1', scanRunId: 'run-1', importFileId: 'file-1' });
|
||||
try {
|
||||
await act(async () => root.render(<FindingReader record={record} title={record.title} toolId="defender-for-iot-vulnerabilities" linkedSheets={[]} resultReferences={[]} search="" position={0} recordCount={1} onPrevious={() => {}} onNext={() => {}} onReview={() => {}} onRiskAcceptance={async () => {}} onScopedRiskAcceptance={async () => {}}/>));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('.accepted-risk-trigger')!.click());
|
||||
const select = document.querySelector<HTMLSelectElement>('.risk-acceptance-scope-select select')!;
|
||||
expect([...select.options].map(option => option.textContent)).toEqual(['Entire finding', 'Device A', 'Device B']);
|
||||
expect(document.querySelector('.risk-acceptance-system-scope')).toBeNull();
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, 'device-id:dev-a');
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelector('.risk-acceptance-system-scope')?.textContent).toContain('CVE-2026-1001 on Device A');
|
||||
expect(document.querySelector('.risk-acceptance-system-scope')?.textContent).toContain('1 covered CVE');
|
||||
expect(control('Affected assets, systems and exclusions').value).toBe('');
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it('opens from the finding header and blocks incomplete acceptance without changing the review', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const container = document.createElement('div'); document.body.append(container);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||
import type { FindingRecord } from '../../src/domain/finding';
|
||||
import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance';
|
||||
import { appendScopedRiskAcceptance, type ScopedRiskAcceptanceTarget } from '../../src/domain/scoped-risk-acceptance';
|
||||
import { defaultReview, type Review } from '../../src/domain/review';
|
||||
import { RiskRegister, riskRegisterEntries } from '../../src/features/dashboard/RiskRegister';
|
||||
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
||||
|
|
@ -25,6 +26,23 @@ function dataset(records: FindingRecord[], duplicate: FindingRecord): ImportedDa
|
|||
}
|
||||
|
||||
describe('Client Info risk register', () => {
|
||||
it('shows one register entry for one grouped device snapshot', () => {
|
||||
const target: ScopedRiskAcceptanceTarget = {
|
||||
kind: 'device-snapshot',
|
||||
device: { key: 'device-id:dev-a', displayName: 'Device A', ip: '192.0.2.10', site: 'Synthetic site', identityMethod: 'device-id', identityConfidence: 'high' },
|
||||
members: [
|
||||
{ findingDefinitionId: 'definition-1', cve: 'CVE-2026-1001', capturedOccurrences: [{ occurrenceId: 'occurrence-1', scanRunId: 'run-1', importFileId: 'file-1', sourcePosition: { rowIndex: 1 } }] },
|
||||
{ findingDefinitionId: 'definition-2', cve: 'CVE-2026-1002', capturedOccurrences: [{ occurrenceId: 'occurrence-2', scanRunId: 'run-1', importFileId: 'file-1', sourcePosition: { rowIndex: 2 } }] }
|
||||
],
|
||||
capturedScanRunIds: ['run-1'], capturedAt: `${today}T08:00:00.000Z`
|
||||
};
|
||||
const acceptance = appendScopedRiskAcceptance(undefined, 'project-1', 'snapshot-1', target, riskCommand('accept', today), `${today}T08:00:00.000Z`);
|
||||
const entries = riskRegisterEntries(dataset([], record('duplicate', 'none', 'Unused', 'Defender')), {}, today, [acceptance]);
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({ id: 'snapshot-1', title: 'All current findings on Device A', state: 'Accepted', coveredCves: ['CVE-2026-1001', 'CVE-2026-1002'] });
|
||||
});
|
||||
|
||||
it('deduplicates projected occurrences and keeps their current governance state', () => {
|
||||
const first = record('record-1', 'occurrence-1', 'Synthetic identity risk', 'Purple Knight');
|
||||
const duplicate = record('record-1-copy', 'occurrence-1', 'Synthetic identity risk', 'Purple Knight');
|
||||
|
|
|
|||
|
|
@ -91,6 +91,37 @@ function importData(run: ScanRun, options: { occurrenceId?: string; ruleId?: str
|
|||
};
|
||||
}
|
||||
|
||||
function defenderRun(sequence: number): ScanRun {
|
||||
return { ...scanRun(sequence), tool: { id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', version: '1.2.0' } };
|
||||
}
|
||||
|
||||
function defenderImportData(run: ScanRun, entries: Array<{ definitionId: string; occurrenceId: string; cve: string; devices: string[] }>): Required<ImportStagingData> {
|
||||
const base = importData(run, { occurrenceId: entries[0].occurrenceId, sha256: `${run.sequence}`.repeat(64).slice(0, 64) });
|
||||
const importFileId = base.importFiles[0].id;
|
||||
const sourceTableId = base.sourceTables[0].id;
|
||||
return {
|
||||
...base,
|
||||
importFiles: [{ ...base.importFiles[0], detectedTool: run.tool, processedWith: { ...base.importFiles[0].processedWith, profileId: run.tool.id } }],
|
||||
findingDefinitions: entries.map(entry => ({
|
||||
id: entry.definitionId, canonicalKey: `${run.tool.id}:${entry.cve}`, title: entry.cve,
|
||||
frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp
|
||||
})),
|
||||
findingOccurrences: entries.map((entry, index) => ({
|
||||
id: entry.occurrenceId, definitionId: entry.definitionId, scanRunId: run.id, importFileId, sourceTableId,
|
||||
sourceIdentity: { tool: run.tool, originalRuleId: entry.cve, canonicalRuleId: entry.cve, originalTitle: entry.cve },
|
||||
sourcePosition: { rowIndex: index + 1 },
|
||||
originalFields: [{ key: 'source-observations', label: 'Source Observations', columnIndex: 0, value: entry.devices.map((deviceId, deviceIndex) => ({
|
||||
name: `Device ${deviceId}`, ip: `192.0.2.${deviceIndex + 10}`, site: 'Synthetic site', sensor: 'sensor-a', cve: entry.cve,
|
||||
severity: 'High', description: 'Synthetic finding', additionalSourceFields: { 'Device ID': deviceId },
|
||||
source: { tableIndex: 0, rowIndex: index + deviceIndex + 1 }
|
||||
})) }],
|
||||
fieldMappings: [], severity: { originalValue: 'High', normalizedValue: 'high', provenance },
|
||||
status: { originalValue: 'Open', normalizedValue: 'open', provenance }
|
||||
})),
|
||||
evidence: [], diagnostics: []
|
||||
};
|
||||
}
|
||||
|
||||
async function repositoryWithProject(): Promise<LocalAssessorRepository> {
|
||||
const repository = new LocalAssessorRepository();
|
||||
await repository.saveProject(project);
|
||||
|
|
@ -127,6 +158,54 @@ function review(revision: number, status: FindingReview['workflowStatus'] = 'ope
|
|||
}
|
||||
|
||||
describe('LocalAssessorRepository contract', () => {
|
||||
it('stores immutable device-finding and server-derived Defender device snapshots', async () => {
|
||||
const repository = await repositoryWithProject();
|
||||
const run = defenderRun(1);
|
||||
await commitRun(repository, run, defenderImportData(run, [
|
||||
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-cve-1', cve: 'CVE-2026-1001', devices: ['DEV-A', 'DEV-B'] },
|
||||
{ definitionId: 'definition-cve-2', occurrenceId: 'occurrence-cve-2', cve: 'CVE-2026-1002', devices: ['DEV-A'] }
|
||||
]));
|
||||
|
||||
const pair = await repository.recordScopedRiskAcceptance(project.id, {
|
||||
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:dev-a' },
|
||||
command: riskCommand('accept'), expectedRevision: 0
|
||||
});
|
||||
expect(pair.target).toMatchObject({ kind: 'device-finding', device: { key: 'device-id:dev-a' }, members: [{ findingDefinitionId: 'definition-cve-1', cve: 'CVE-2026-1001' }] });
|
||||
expect(pair.target.members[0].capturedOccurrences).toEqual([expect.objectContaining({ occurrenceId: 'occurrence-cve-1', scanRunId: run.id })]);
|
||||
|
||||
const snapshot = await repository.recordScopedRiskAcceptance(project.id, {
|
||||
target: { kind: 'device-snapshot', scanRunIds: [run.id], deviceKey: 'device-id:dev-a' },
|
||||
command: riskCommand('accept'), expectedRevision: 0
|
||||
});
|
||||
expect(snapshot.target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
||||
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
||||
acceptanceId: snapshot.id, command: riskCommand('draft'), expectedRevision: 0
|
||||
})).rejects.toBeInstanceOf(RepositoryConflictError);
|
||||
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
||||
acceptanceId: snapshot.id,
|
||||
target: { kind: 'device-snapshot', scanRunIds: [run.id], deviceKey: 'device-id:dev-b' },
|
||||
command: riskCommand('draft'), expectedRevision: snapshot.revision
|
||||
})).rejects.toBeInstanceOf(RepositoryValidationError);
|
||||
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
||||
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:missing' },
|
||||
command: riskCommand('accept'), expectedRevision: 0
|
||||
})).rejects.toBeInstanceOf(RepositoryValidationError);
|
||||
await expect(repository.recordScopedRiskAcceptance('other-project', {
|
||||
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:dev-a' },
|
||||
command: riskCommand('accept'), expectedRevision: 0
|
||||
})).rejects.toThrow();
|
||||
|
||||
const revoked = await repository.recordScopedRiskAcceptance(project.id, {
|
||||
acceptanceId: snapshot.id, command: riskCommand('revoke'), expectedRevision: snapshot.revision
|
||||
});
|
||||
expect(revoked).toMatchObject({ id: snapshot.id, revision: 2 });
|
||||
expect(revoked.revisions.map(item => item.action)).toEqual(['accept', 'revoke']);
|
||||
expect((await repository.listScopedRiskAcceptances(project.id))).toHaveLength(2);
|
||||
expect((await repository.exportProject(project.id)).scopedRiskAcceptances).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: pair.id }), expect.objectContaining({ id: snapshot.id, revision: 2 })
|
||||
]));
|
||||
});
|
||||
|
||||
it('stores risk decisions atomically with optimistic concurrency, export and reimport history', async () => {
|
||||
const repository = await repositoryWithProject();
|
||||
await commitRun(repository, scanRun(1));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { databaseMigrations } from '../../server/postgres/migration-catalog';
|
||||
|
||||
describe('PostgreSQL scoped risk acceptance migration', () => {
|
||||
it('registers one project-owned indexed target-snapshot table', () => {
|
||||
const migration = databaseMigrations.find(item => item.version === '028');
|
||||
expect(migration).toMatchObject({ version: '028', name: 'scoped-risk-acceptances' });
|
||||
expect(migration?.sql).toContain('CREATE TABLE assessor_scoped_risk_acceptances');
|
||||
expect(migration?.sql).toContain('REFERENCES assessor_projects(id) ON DELETE CASCADE');
|
||||
expect(migration?.sql).toContain("scope_kind IN ('device-finding', 'device-snapshot')");
|
||||
expect(migration?.sql).toContain('(project_id, device_key, updated_at DESC, id)');
|
||||
expect(migration?.sql).toContain('data jsonb NOT NULL');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runDatabaseMigrations } from '../../server/postgres/migration-runner';
|
||||
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';
|
||||
import type { Assessment, AssessmentProject, ScanRun } from '../../src/domain/canonical-model';
|
||||
import type { ImportStagingData } from '../../src/persistence/assessor-repository';
|
||||
import { RepositoryConflictError } from '../../src/persistence/repository-errors';
|
||||
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
||||
|
||||
const connectionString = process.env.ASSESSOR_TEST_DATABASE_URL;
|
||||
const describePostgres = connectionString ? describe : describe.skip;
|
||||
const timestamp = '2026-09-21T08:00:00.000Z';
|
||||
const project: AssessmentProject = { id: 'pg-scoped-risk-project', name: 'Scoped risk integration', createdAt: timestamp, updatedAt: timestamp, attributes: {} };
|
||||
const assessment: Assessment = { id: 'pg-scoped-risk-assessment', projectId: project.id, name: 'Scoped risk assessment', createdAt: timestamp, updatedAt: timestamp };
|
||||
const scanRun: ScanRun = { id: 'pg-scoped-risk-run', assessmentId: assessment.id, sequence: 1, label: 'Defender run', tool: { id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', version: '1.2.0' }, importedAt: timestamp };
|
||||
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
|
||||
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' };
|
||||
|
||||
function data(): ImportStagingData {
|
||||
const observations = (cve: string, rowIndex: number) => [{
|
||||
name: 'Device A', ip: '192.0.2.10', site: 'Synthetic site', sensor: 'sensor-a', cve, severity: 'High', description: 'Synthetic',
|
||||
additionalSourceFields: { 'Device ID': 'DEV-A' }, source: { tableIndex: 0, rowIndex }
|
||||
}];
|
||||
return {
|
||||
importFiles: [{
|
||||
id: 'pg-scoped-risk-file', scanRunId: scanRun.id, fileName: 'defender.csv', format: 'CSV', sizeBytes: 100,
|
||||
importedAt: timestamp, detectedTool: scanRun.tool, detection: confidence,
|
||||
processedWith: {
|
||||
parserId: 'csv', parserVersion: '1.0.0', profileId: scanRun.tool.id, profileVersion: '1.2.0',
|
||||
mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0',
|
||||
compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true
|
||||
},
|
||||
sourceArtifact: { kind: 'not-retained', reason: 'Synthetic integration test' }, sha256: '9'.repeat(64)
|
||||
}],
|
||||
sourceTables: [{ id: 'pg-scoped-risk-table', importFileId: 'pg-scoped-risk-file', name: 'Vulnerabilities', index: 0, rowCount: 2, columnCount: 2, attributes: {} }],
|
||||
findingDefinitions: ['CVE-2026-1001', 'CVE-2026-1002'].map((cve, index) => ({
|
||||
id: `pg-scoped-risk-definition-${index + 1}`, canonicalKey: `${scanRun.tool.id}:${cve}`, title: cve,
|
||||
frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp
|
||||
})),
|
||||
findingOccurrences: ['CVE-2026-1001', 'CVE-2026-1002'].map((cve, index) => ({
|
||||
id: `pg-scoped-risk-occurrence-${index + 1}`, definitionId: `pg-scoped-risk-definition-${index + 1}`,
|
||||
scanRunId: scanRun.id, importFileId: 'pg-scoped-risk-file', sourceTableId: 'pg-scoped-risk-table',
|
||||
sourceIdentity: { tool: scanRun.tool, originalRuleId: cve, canonicalRuleId: cve, originalTitle: cve },
|
||||
sourcePosition: { rowIndex: index + 1 },
|
||||
originalFields: [{ key: 'source-observations', label: 'Source Observations', columnIndex: 0, value: observations(cve, index + 1) }],
|
||||
fieldMappings: [], severity: { originalValue: 'High', normalizedValue: 'high' as const, provenance },
|
||||
status: { originalValue: 'Open', normalizedValue: 'open' as const, provenance }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
describePostgres('PostgreSQL scoped risk acceptance persistence', () => {
|
||||
let pool: Pool;
|
||||
let repository: PostgresAssessorRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = new Pool({ connectionString, max: 2, connectionTimeoutMillis: 5000 });
|
||||
await runDatabaseMigrations(pool);
|
||||
await pool.query('DELETE FROM assessor_projects WHERE id=$1', [project.id]);
|
||||
repository = await PostgresAssessorRepository.create(pool);
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pool) {
|
||||
await pool.query('DELETE FROM assessor_projects WHERE id=$1', [project.id]);
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists, conflicts, revokes, exports, and cascades a grouped decision', async () => {
|
||||
await repository.saveProject(project);
|
||||
await repository.saveAssessment(assessment);
|
||||
const session = await repository.beginImport({ sessionId: 'pg-scoped-risk-session', projectId: project.id, assessmentId: assessment.id, scanRun });
|
||||
await session.stage(data());
|
||||
await session.commit();
|
||||
const accepted = await repository.recordScopedRiskAcceptance(project.id, {
|
||||
target: { kind: 'device-snapshot', scanRunIds: [scanRun.id], deviceKey: 'device-id:dev-a' },
|
||||
command: riskCommand('accept'), expectedRevision: 0
|
||||
});
|
||||
expect(accepted.target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
||||
await expect(repository.recordScopedRiskAcceptance(project.id, { acceptanceId: accepted.id, command: riskCommand('draft'), expectedRevision: 0 }))
|
||||
.rejects.toBeInstanceOf(RepositoryConflictError);
|
||||
const revoked = await repository.recordScopedRiskAcceptance(project.id, { acceptanceId: accepted.id, command: riskCommand('revoke'), expectedRevision: 1 });
|
||||
expect(revoked.revisions.map(item => item.action)).toEqual(['accept', 'revoke']);
|
||||
await expect(repository.listScopedRiskAcceptances(project.id)).resolves.toEqual([expect.objectContaining({ id: accepted.id, revision: 2 })]);
|
||||
await expect(repository.exportProject(project.id)).resolves.toMatchObject({ scopedRiskAcceptances: [expect.objectContaining({ id: accepted.id })] });
|
||||
await repository.deleteProject(project.id, { projectId: project.id, cascade: true });
|
||||
await expect(pool.query('SELECT count(*)::integer AS count FROM assessor_scoped_risk_acceptances WHERE project_id=$1', [project.id]))
|
||||
.resolves.toMatchObject({ rows: [{ count: 0 }] });
|
||||
}, 30000);
|
||||
});
|
||||
|
|
@ -40,6 +40,51 @@ describe('batch import HTTP workflow', () => {
|
|||
const mutate = (path: string, body: unknown) => fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1' }, body: JSON.stringify(body)
|
||||
});
|
||||
it('creates project-owned Defender device scopes and derives grouped snapshot membership on the server', async () => {
|
||||
const bytes = Buffer.from([
|
||||
'Device ID,Device Name,IP Address,Site,Sensor,CVE,CVSS Score,Severity,Component,Status',
|
||||
'DEV-A,PLC-A,192.0.2.10,Synthetic site,SENSOR-1,CVE-2026-1001,9.1,Critical,Component A,Open',
|
||||
'DEV-B,PLC-B,192.0.2.20,Synthetic site,SENSOR-1,CVE-2026-1001,9.1,Critical,Component A,Open',
|
||||
'DEV-A,PLC-A,192.0.2.10,Synthetic site,SENSOR-1,CVE-2026-1002,7.5,High,Component B,Open'
|
||||
].join('\n'));
|
||||
const file = { fileName: 'defender-cves.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ',' };
|
||||
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file] })).json() as ImportBatchPreview;
|
||||
const entry = preview.entries[0];
|
||||
const complete = await (await mutate('/api/v1/import-batches/complete', {
|
||||
batchId: preview.batchId,
|
||||
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }]
|
||||
})).json() as ImportBatchCompleteResponse;
|
||||
const model = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
||||
const cve1 = model.findingOccurrences.find(item => item.sourceIdentity.canonicalRuleId === 'CVE-2026-1001')!;
|
||||
const path = `/api/v1/projects/${complete.projectId}/risk-acceptances/scoped`;
|
||||
const pairBody = {
|
||||
target: { kind: 'device-finding', occurrenceId: cve1.id, deviceKey: 'device-id:dev-a' },
|
||||
command: riskCommand(), expectedRevision: 0
|
||||
};
|
||||
const noHeader = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pairBody) });
|
||||
expect(noHeader.status).toBe(403);
|
||||
expect((await mutate(path, { ...pairBody, target: { kind: 'device-snapshot', deviceKey: 'device-id:dev-a', scanRunIds: [] } })).status).toBe(400);
|
||||
const pairResponse = await mutate(path, pairBody);
|
||||
expect(pairResponse.status).toBe(200);
|
||||
await expect(pairResponse.json()).resolves.toMatchObject({ acceptance: {
|
||||
target: { kind: 'device-finding', device: { key: 'device-id:dev-a' }, members: [{ cve: 'CVE-2026-1001' }] },
|
||||
revision: 1
|
||||
} });
|
||||
|
||||
const groupedResponse = await mutate(path, {
|
||||
target: { kind: 'device-snapshot', scanRunIds: [complete.scanRunId], deviceKey: 'device-id:dev-a' },
|
||||
command: riskCommand(), expectedRevision: 0
|
||||
});
|
||||
expect(groupedResponse.status).toBe(200);
|
||||
const grouped = await groupedResponse.json() as { acceptance: { id: string; revision: number; target: { members: Array<{ cve: string }> } } };
|
||||
expect(grouped.acceptance.target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
||||
expect((await mutate(path, { acceptanceId: grouped.acceptance.id, command: riskCommand('draft'), expectedRevision: 0 })).status).toBe(409);
|
||||
await expect((await fetch(`${baseUrl}${path}`)).json()).resolves.toHaveLength(2);
|
||||
await expect((await fetch(`${baseUrl}/api/v1/projects/${complete.projectId}/export`)).json()).resolves.toMatchObject({
|
||||
scopedRiskAcceptances: [expect.objectContaining({ projectId: complete.projectId }), expect.objectContaining({ projectId: complete.projectId })]
|
||||
});
|
||||
});
|
||||
|
||||
it('records risk acceptance through the protected API and preserves it on reload and ordinary review updates', async () => {
|
||||
const bytes = Buffer.from('Rule ID;Title;Severity;Status\nSYN-1;Synthetic risk;High;Failed');
|
||||
const file = { fileName: 'synthetic.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
||||
|
|
|
|||
Loading…
Reference in New Issue