feat: scoped risk acceptance for Defender devices
Adapted pick of d9ff6a8: device-finding and device-snapshot targets with immutable snapshots, migration 028, repositories, API, Defender panel, Finding Reader and the Accepted Risks register. Unlike the original, the register keeps the base rule for its current count, and a scoped decision covers only the occurrences it captured. After a reimport it stays with its full history, shows Review required and covers nothing, like a whole-finding acceptance.
This commit is contained in:
parent
8beea97ce4
commit
2eafefc5a1
|
|
@ -4231,6 +4231,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
|
||||||
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
||||||
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
||||||
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
||||||
|
| 2026-10-02 | Phasen 7/13A: `d9ff6a8` (geraetebezogene Risikoakzeptanz fuer Defender) angepasst uebernommen. Neben der bestehenden Akzeptanz des ganzen Findings gibt es die Ziele `device-finding` (ein Device mal eine CVE) und `device-snapshot` (alle aktuellen CVEs eines Device) mit unveraenderlichem Zielabbild und append-only Historie. Migration `028_scoped_risk_acceptances.sql` legt die projektgebundene Tabelle `assessor_scoped_risk_acceptances` an (Cascade bei Projektloeschung); beide Repositories, `GET`/`POST /api/v1/projects/:id/risk-acceptances/scoped`, App-State, Defender-Panel (Abdeckungszeile, deckende Entscheidungen, `Accept all current findings`), Finding Reader/Dialog und das Accepted-Risks-Register sind erweitert. Nutzerentscheidung 2026-10-02, abweichend vom Original: (1) Die Zaehlung `current` im Register bleibt bei der Basisregel `Accepted`, `Scheduled`, `Review due`, `Review required`. (2) Geraetebezogene Entscheidungen folgen der Regel fuer ganze Findings: sie decken nur die erfassten Occurrences; nach einem Reimport oder entfernten Scan-Lauf sowie bei einer nicht erfassten Occurrence desselben Device und derselben CVE bleibt die Entscheidung mit vollstaendiger Historie erhalten, zeigt `Review required` und deckt nichts. Der Marker `capturedScopeReplaced` wird bei jedem Lesen aus den aktuellen Scan-Laeufen abgeleitet und nie gespeichert. Offen: Ersetzte geraetebezogene Entscheidungen fliessen nicht in die Reimport-Transferzaehlung je Datei und damit nicht in den Data-Hub-Verarbeitungsstatus ein; das Defender-Panel listet nur deckende Entscheidungen. Neue Regressionen: Domain (erfasste gegen ersetzte Occurrence, Marker, Widerruf, keine Persistenz des Markers), lokales Repository (Reimport desselben Berichts mit geaenderter Device-Identitaet) und Register. Der opt-in PostgreSQL-Integrationstest lief nicht; der PostgreSQL-Lesepfad des Markers ist damit nur typgeprueft. Vor der Migration wurde das verifizierte Backup `backups/assessor-pre-028-20261002-132810.dump` erstellt. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.908 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||||
| 2026-10-02 | Phasen 6/7: Defender-Teile von `b438f25` uebernommen (nach `c7c716c`, weil der Commit zunaechst nur als NVD-Korrektur eingeordnet war). CVE-, Device- und Site-Tabelle des Defender-Panels klappen ihre Details direkt unter der angeklickten Zeile auf und wieder zu (Dreieck-Schalter, `aria-expanded`/`aria-controls`); die CVE-Tabelle fuehrt die Beschreibung nur noch im Detail. Der Extractor gibt die Tabelle `Affected Devices` als `Name`, `IP Address`, `Scanner`, `Site` aus. Abweichend vom Original nutzt die aufgeklappte Zeile nach Nutzerentscheidung die blaue Tabellenzeilen-Markierung (`--table-row-marking`, `--table-row-marking-surface`) statt Gelb, und der Drilldown bleibt auf den gemeinsamen Kachelrollen. Nicht uebernommen: die `baseSeverity`-Korrektur im NVD-Client samt Test (CVE-Anreicherung ausgelassen) und der `.gitignore`-Eintrag. Kein Profilversionssprung wie im Original; die Tabellenspalten liegen bereits unter dem danach uebernommenen Profil 1.2.0, die lokale Datenbank enthaelt keine Defender-Importe. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.896 bestandenen und 17 uebersprungenen Tests (ein lastbedingter Einzelausfall in `node-import-engine.test.ts` im ersten Lauf, allein und im Wiederholungslauf bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
| 2026-10-02 | Phasen 6/7: Defender-Teile von `b438f25` uebernommen (nach `c7c716c`, weil der Commit zunaechst nur als NVD-Korrektur eingeordnet war). CVE-, Device- und Site-Tabelle des Defender-Panels klappen ihre Details direkt unter der angeklickten Zeile auf und wieder zu (Dreieck-Schalter, `aria-expanded`/`aria-controls`); die CVE-Tabelle fuehrt die Beschreibung nur noch im Detail. Der Extractor gibt die Tabelle `Affected Devices` als `Name`, `IP Address`, `Scanner`, `Site` aus. Abweichend vom Original nutzt die aufgeklappte Zeile nach Nutzerentscheidung die blaue Tabellenzeilen-Markierung (`--table-row-marking`, `--table-row-marking-surface`) statt Gelb, und der Drilldown bleibt auf den gemeinsamen Kachelrollen. Nicht uebernommen: die `baseSeverity`-Korrektur im NVD-Client samt Test (CVE-Anreicherung ausgelassen) und der `.gitignore`-Eintrag. Kein Profilversionssprung wie im Original; die Tabellenspalten liegen bereits unter dem danach uebernommenen Profil 1.2.0, die lokale Datenbank enthaelt keine Defender-Importe. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.896 bestandenen und 17 uebersprungenen Tests (ein lastbedingter Einzelausfall in `node-import-engine.test.ts` im ersten Lauf, allein und im Wiederholungslauf bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||||
| 2026-10-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
| 2026-10-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||||
| 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
| 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Accepted Risk: decision record and storage contract
|
# 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
|
## 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.
|
- 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.
|
- 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.
|
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
|
## 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.
|
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
|
## 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 on the merged base, the canonical datastore was saved to `backups/assessor-pre-028-20261002-132810.dump`; its custom-format archive catalog was readable. 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.
|
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.
|
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
|
## 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. They follow the whole-finding rule (explicit user decision 2026-10-02): a decision covers only the device/CVE occurrences it captured. When a captured scan run is replaced by a reimport or removed, or when the same device and CVE are reached only through an occurrence the decision never captured, the decision is kept with its complete history, displays `Review required` and provides no coverage. Nothing is re-attached by device name, title or position; a new CVE, changed device key, unresolved identity or ambiguous identity is never covered. The replacement marker `capturedScopeReplaced` is derived on every read from the project's current scan runs and is never stored. A new decision can explicitly cover the replacement after reassessment. The snapshot itself never grows after creation. Not yet aligned with whole-finding reviews: a replaced scoped decision is not counted in the per-file reimport transfer summary and therefore does not change the Data Hub processing state.
|
||||||
|
|
||||||
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.
|
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:
|
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.
|
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.
|
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.
|
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.
|
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 { RiskAcceptanceValidationError, type RiskAcceptanceCommand } from '../src/domain/risk-acceptance';
|
||||||
|
import type { ScopedRiskAcceptanceWrite } from '../src/domain/scoped-risk-acceptance';
|
||||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
|
@ -1279,6 +1280,23 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
||||||
await repository.saveReview(body.review, body.expectedRevision);
|
await repository.saveReview(body.review, body.expectedRevision);
|
||||||
status = 200; sendJson(response, status, { review: body.review, repositoryRevision: repository.revision }, requestId); return;
|
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') {
|
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);
|
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.');
|
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 resultDbMaterialization from './migrations/024_result_db_materialization.
|
||||||
import aiOverviewRuns from './migrations/025_ai_overview_runs.sql?raw';
|
import aiOverviewRuns from './migrations/025_ai_overview_runs.sql?raw';
|
||||||
import regulatoryMapping from './migrations/026_regulatory_mapping.sql?raw';
|
import regulatoryMapping from './migrations/026_regulatory_mapping.sql?raw';
|
||||||
import vulnerabilityFindingResultType from './migrations/027_vulnerability_finding_result_type.sql?raw';
|
import vulnerabilityFindingResultType from './migrations/027_vulnerability_finding_result_type.sql?raw';
|
||||||
|
import scopedRiskAcceptances from './migrations/028_scoped_risk_acceptances.sql?raw';
|
||||||
|
|
||||||
export interface DatabaseMigration {
|
export interface DatabaseMigration {
|
||||||
version: string;
|
version: string;
|
||||||
|
|
@ -55,5 +56,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||||
{ version: '024', name: 'result-db-materialization', sql: resultDbMaterialization },
|
{ version: '024', name: 'result-db-materialization', sql: resultDbMaterialization },
|
||||||
{ version: '025', name: 'ai-overview-runs', sql: aiOverviewRuns },
|
{ version: '025', name: 'ai-overview-runs', sql: aiOverviewRuns },
|
||||||
{ version: '026', name: 'regulatory-mapping', sql: regulatoryMapping },
|
{ version: '026', name: 'regulatory-mapping', sql: regulatoryMapping },
|
||||||
{ version: '027', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType }
|
{ version: '027', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType },
|
||||||
|
{ 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,13 @@
|
||||||
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../../src/domain/risk-acceptance';
|
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../../src/domain/risk-acceptance';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
appendScopedRiskAcceptance,
|
||||||
|
scopedRiskTargetFromOccurrences,
|
||||||
|
withCapturedScopeStatus,
|
||||||
|
type ScopedRiskAcceptance,
|
||||||
|
type ScopedRiskAcceptanceTarget,
|
||||||
|
type ScopedRiskAcceptanceWrite
|
||||||
|
} from '../../src/domain/scoped-risk-acceptance';
|
||||||
import type { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
|
import type { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
|
||||||
import type {
|
import type {
|
||||||
Assessment,
|
Assessment,
|
||||||
|
|
@ -385,6 +394,7 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record<string, DbStructureLayerId> = {
|
||||||
assessor_finding_occurrence_categories: 'result',
|
assessor_finding_occurrence_categories: 'result',
|
||||||
assessor_evidence: 'result',
|
assessor_evidence: 'result',
|
||||||
assessor_reviews: 'governance',
|
assessor_reviews: 'governance',
|
||||||
|
assessor_scoped_risk_acceptances: 'governance',
|
||||||
assessor_comparisons: 'governance',
|
assessor_comparisons: 'governance',
|
||||||
assessor_projects: 'core',
|
assessor_projects: 'core',
|
||||||
assessor_assessments: 'core',
|
assessor_assessments: 'core',
|
||||||
|
|
@ -2713,6 +2723,79 @@ 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 (await this.scopedWithStatus(client, projectId, [saved]))[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]> {
|
||||||
|
return this.scopedWithStatus(this.pool, projectId, 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]
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scopedWithStatus(queryable: Queryable, projectId: string, values: ScopedRiskAcceptance[]): Promise<ScopedRiskAcceptance[]> {
|
||||||
|
if (!values.length) return values;
|
||||||
|
const current = await queryable.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.data->>'supersededByScanRunId' IS NULL`, [projectId]
|
||||||
|
);
|
||||||
|
const currentIds = new Set(current.rows.map(row => row.id));
|
||||||
|
return values.map(value => withCapturedScopeStatus(value, currentIds));
|
||||||
|
}
|
||||||
|
|
||||||
async listReviews(projectId: string): Promise<FindingReview[]> {
|
async listReviews(projectId: string): Promise<FindingReview[]> {
|
||||||
return dataRows(await this.pool.query<DataRow<FindingReview>>(`
|
return dataRows(await this.pool.query<DataRow<FindingReview>>(`
|
||||||
SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv
|
SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv
|
||||||
|
|
@ -2829,6 +2912,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
|
||||||
findingOccurrences: models.flatMap(model => model.findingOccurrences),
|
findingOccurrences: models.flatMap(model => model.findingOccurrences),
|
||||||
evidence: models.flatMap(model => model.evidence),
|
evidence: models.flatMap(model => model.evidence),
|
||||||
reviews: await this.listReviews(projectId),
|
reviews: await this.listReviews(projectId),
|
||||||
|
scopedRiskAcceptances: await this.listScopedRiskAcceptances(projectId),
|
||||||
diagnostics: models.flatMap(model => model.diagnostics),
|
diagnostics: models.flatMap(model => model.diagnostics),
|
||||||
comparisons: dataRows(await this.pool.query<DataRow<StoredComparison>>(
|
comparisons: dataRows(await this.pool.query<DataRow<StoredComparison>>(
|
||||||
'SELECT data FROM assessor_comparisons WHERE project_id = $1 ORDER BY id', [projectId]
|
'SELECT data FROM assessor_comparisons WHERE project_id = $1 ORDER BY id', [projectId]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { trackActivity } from '../platform/activity-store';
|
import { trackActivity } from '../platform/activity-store';
|
||||||
import { requestActivity } from './request-activity';
|
import { requestActivity } from './request-activity';
|
||||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
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 { Assessment, AssessmentProject, CanonicalAssessmentModel, FindingReview, ImportFileRecord } from '../domain/canonical-model';
|
||||||
import type {
|
import type {
|
||||||
UnifiedResultCategory,
|
UnifiedResultCategory,
|
||||||
|
|
@ -301,6 +302,12 @@ export class AssessorApiClient {
|
||||||
recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<{ review: FindingReview; repositoryRevision: number }> {
|
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 }) });
|
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> {
|
queryUnifiedResults(query: UnifiedResultQuery = {}): Promise<UnifiedResultQueryResult> {
|
||||||
return this.request(unifiedQueryPath('/results', query));
|
return this.request(unifiedQueryPath('/results', query));
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AssessorApiClient } from '../api/assessor-api-client';
|
||||||
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
|
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
|
||||||
import type { FindingReview } from '../domain/canonical-model';
|
import type { FindingReview } from '../domain/canonical-model';
|
||||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||||
|
import type { ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance';
|
||||||
import { defaultReview } from '../domain/review';
|
import { defaultReview } from '../domain/review';
|
||||||
import type { ImportProfile } from '../domain/import-profile';
|
import type { ImportProfile } from '../domain/import-profile';
|
||||||
import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow';
|
import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow';
|
||||||
|
|
@ -51,6 +52,7 @@ interface AppContextValue extends AppState {
|
||||||
closeImportWorkflow(): void;
|
closeImportWorkflow(): void;
|
||||||
setActiveSheet(sheetId: string): void;
|
setActiveSheet(sheetId: string): void;
|
||||||
recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise<void>;
|
recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise<void>;
|
||||||
|
recordScopedRiskAcceptance(write: ScopedRiskAcceptanceWrite): Promise<void>;
|
||||||
setView(view: ViewMode): void;
|
setView(view: ViewMode): void;
|
||||||
setSearch(search: string): void;
|
setSearch(search: string): void;
|
||||||
setSortColumn(column: string): void;
|
setSortColumn(column: string): void;
|
||||||
|
|
@ -206,7 +208,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
const activeSheet = snapshot.dataset.sheets[0];
|
const activeSheet = snapshot.dataset.sheets[0];
|
||||||
const defaults = profile.defaults(activeSheet);
|
const defaults = profile.defaults(activeSheet);
|
||||||
setState(current => ({
|
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,
|
projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn,
|
||||||
sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [],
|
sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [],
|
||||||
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
||||||
|
|
@ -297,9 +299,9 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
const report = createImportReport(preview, importedIds);
|
const report = createImportReport(preview, importedIds);
|
||||||
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
|
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
|
||||||
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
|
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
|
||||||
const persistedWorkspace = persistedProjectId
|
const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId
|
||||||
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
|
? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])])
|
||||||
: undefined;
|
: [undefined, []];
|
||||||
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
||||||
const localReviews = await reviewRepository.load(projectId);
|
const localReviews = await reviewRepository.load(projectId);
|
||||||
const hydrated = persistedWorkspace
|
const hydrated = persistedWorkspace
|
||||||
|
|
@ -311,7 +313,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
const activeProfile = profileFor(workDataset);
|
const activeProfile = profileFor(workDataset);
|
||||||
const defaults = activeProfile.defaults(activeSheet);
|
const defaults = activeProfile.defaults(activeSheet);
|
||||||
setState(current => ({
|
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,
|
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
|
||||||
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
||||||
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
||||||
|
|
@ -408,9 +410,9 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
const report = createImportReport(preview, importedIds);
|
const report = createImportReport(preview, importedIds);
|
||||||
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
|
localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report));
|
||||||
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
|
const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified);
|
||||||
const persistedWorkspace = persistedProjectId
|
const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId
|
||||||
? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId)
|
? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])])
|
||||||
: undefined;
|
: [undefined, []];
|
||||||
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
const workDataset = persistedWorkspace?.dataset ?? dataset;
|
||||||
const localReviews = await reviewRepository.load(projectId);
|
const localReviews = await reviewRepository.load(projectId);
|
||||||
const hydrated = persistedWorkspace
|
const hydrated = persistedWorkspace
|
||||||
|
|
@ -422,7 +424,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
const activeProfile = profileFor(workDataset);
|
const activeProfile = profileFor(workDataset);
|
||||||
const defaults = activeProfile.defaults(activeSheet);
|
const defaults = activeProfile.defaults(activeSheet);
|
||||||
setState(current => ({
|
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,
|
view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn,
|
||||||
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection,
|
||||||
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter),
|
||||||
|
|
@ -463,19 +465,22 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await showActivityFrame();
|
await showActivityFrame();
|
||||||
const { models, dataset, assessmentIds } = await loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds, { onProgress: activity.update });
|
const [{ models, dataset, assessmentIds }, scopedRiskAcceptances] = await Promise.all([
|
||||||
|
loadCanonicalProjectWorkspace(assessorApi, projectId, scanRunIds, selectedAssessmentIds, { onProgress: activity.update }),
|
||||||
|
assessorApi.listScopedRiskAcceptances(projectId).catch(() => [])
|
||||||
|
]);
|
||||||
if (requestId !== openProjectRequest.current) { activity.cancel(); return false; }
|
if (requestId !== openProjectRequest.current) { activity.cancel(); return false; }
|
||||||
if (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name };
|
if (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name };
|
||||||
if (!models.length) {
|
if (!models.length) {
|
||||||
persistedReviews.current = new Map();
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review]));
|
persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review]));
|
||||||
setActiveAssessmentIds(assessmentIds);
|
setActiveAssessmentIds(assessmentIds);
|
||||||
setWorkScopeId(scopeId);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
activity.update('Loading work tracking and preparing the client view');
|
activity.update('Loading work tracking and preparing the client view');
|
||||||
|
|
@ -504,6 +509,7 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
dataset,
|
dataset,
|
||||||
activeSheetId: activeSheet?.id ?? '',
|
activeSheetId: activeSheet?.id ?? '',
|
||||||
reviews,
|
reviews,
|
||||||
|
scopedRiskAcceptances,
|
||||||
projectId,
|
projectId,
|
||||||
search: '',
|
search: '',
|
||||||
actionOnly: false,
|
actionOnly: false,
|
||||||
|
|
@ -644,6 +650,14 @@ export function AppProvider({ children }: PropsWithChildren) {
|
||||||
reviewSaveQueues.current.set(key, queued);
|
reviewSaveQueues.current.set(key, queued);
|
||||||
return 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,
|
snapshot: () => state.dataset ? createSnapshot(state.dataset, state.reviews) : null,
|
||||||
setTheme,
|
setTheme,
|
||||||
setDelimiter: delimiter => setState(current => ({ ...current, delimiter })),
|
setDelimiter: delimiter => setState(current => ({ ...current, delimiter })),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ImportedDataset } from '../domain/dataset';
|
import type { ImportedDataset } from '../domain/dataset';
|
||||||
import type { Review } from '../domain/review';
|
import type { Review } from '../domain/review';
|
||||||
|
import type { ScopedRiskAcceptance } from '../domain/scoped-risk-acceptance';
|
||||||
import type { DynamicFacetFilters } from '../features/search-filter/dynamic-facets';
|
import type { DynamicFacetFilters } from '../features/search-filter/dynamic-facets';
|
||||||
|
|
||||||
export type ViewMode = 'records' | 'columns' | 'table' | 'raw';
|
export type ViewMode = 'records' | 'columns' | 'table' | 'raw';
|
||||||
|
|
@ -20,6 +21,7 @@ export interface AppState {
|
||||||
actionOnly: boolean;
|
actionOnly: boolean;
|
||||||
selectedRecordId: string;
|
selectedRecordId: string;
|
||||||
reviews: Record<string, Review>;
|
reviews: Record<string, Review>;
|
||||||
|
scopedRiskAcceptances: ScopedRiskAcceptance[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
status: string;
|
status: string;
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
|
|
@ -57,6 +59,7 @@ export const initialAppState: AppState = {
|
||||||
actionOnly: false,
|
actionOnly: false,
|
||||||
selectedRecordId: '',
|
selectedRecordId: '',
|
||||||
reviews: {},
|
reviews: {},
|
||||||
|
scopedRiskAcceptances: [],
|
||||||
projectId: '',
|
projectId: '',
|
||||||
status: 'Ready',
|
status: 'Ready',
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Derived on every read and never stored: a captured scan run was replaced by a
|
||||||
|
* reimport or removed. Like a whole-finding acceptance whose target occurrence
|
||||||
|
* changed, the decision then needs review and covers nothing.
|
||||||
|
*/
|
||||||
|
capturedScopeReplaced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (value.capturedScopeReplaced) return 'Review required' 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the read-time replacement marker from the project's current, non-superseded scan runs. */
|
||||||
|
export function withCapturedScopeStatus(value: ScopedRiskAcceptance, currentScanRunIds: ReadonlySet<string>): ScopedRiskAcceptance {
|
||||||
|
return { ...value, capturedScopeReplaced: value.target.capturedScanRunIds.some(id => !currentScanRunIds.has(id)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScopedCoverageTarget = Pick<DefenderRiskTarget, 'definitionId' | 'device' | 'occurrenceId'>;
|
||||||
|
|
||||||
|
function addressesTarget(value: ScopedRiskAcceptance, target: ScopedCoverageTarget): boolean {
|
||||||
|
return value.target.device.key === target.device.key
|
||||||
|
&& value.target.members.some(member => member.findingDefinitionId === target.definitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturesTarget(value: ScopedRiskAcceptance, target: ScopedCoverageTarget): boolean {
|
||||||
|
return value.target.device.key === target.device.key
|
||||||
|
&& value.target.members.some(member => member.findingDefinitionId === target.definitionId
|
||||||
|
&& member.capturedOccurrences.some(item => item.occurrenceId === target.occurrenceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State against the currently loaded targets. A decision that addresses a current
|
||||||
|
* device and CVE only through an occurrence it never captured needs review, exactly
|
||||||
|
* as a whole-finding acceptance does when its target occurrence differs.
|
||||||
|
*/
|
||||||
|
export function scopedRiskAcceptanceStateForTargets(value: ScopedRiskAcceptance, targets: ScopedCoverageTarget[], today?: string) {
|
||||||
|
const state = scopedRiskAcceptanceState(value, today);
|
||||||
|
if (state === 'Not recorded' || state === 'Revoked' || state === 'Draft') return state;
|
||||||
|
const addressed = targets.filter(target => addressesTarget(value, target));
|
||||||
|
return addressed.length && !addressed.some(target => capturesTarget(value, target)) ? 'Review required' as const : state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopedAcceptanceCovers(value: ScopedRiskAcceptance, target: ScopedCoverageTarget, today?: string): boolean {
|
||||||
|
const state = scopedRiskAcceptanceState(value, today);
|
||||||
|
return (state === 'Accepted' || state === 'Review due') && capturesTarget(value, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1262,7 +1262,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
</nav> : undefined;
|
</nav> : undefined;
|
||||||
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
||||||
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
||||||
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 {
|
const {
|
||||||
|
|
@ -1458,7 +1460,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
scanRunIds: app.activeScanRunIds } : undefined} />
|
scanRunIds: app.activeScanRunIds } : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
|
<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
|
||||||
</div> }, { id: 'security', label: 'Security', content: <ClientSecurityPanel key={reportScopeKey}
|
</div> }, { id: 'security', label: 'Security', content: <ClientSecurityPanel key={reportScopeKey}
|
||||||
dataset={dataset} sheetId={activeSheet.id} toolId={activeSheet.toolId} contextStatus={impactContext.status} {...securityView}/> }]} />
|
dataset={dataset} sheetId={activeSheet.id} toolId={activeSheet.toolId} contextStatus={impactContext.status} {...securityView}/> }]} />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,22 @@ import { useState } from 'react';
|
||||||
import type { ImportedDataset } from '../../domain/dataset';
|
import type { ImportedDataset } from '../../domain/dataset';
|
||||||
import type { FindingRecord } from '../../domain/finding';
|
import type { FindingRecord } from '../../domain/finding';
|
||||||
import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
|
import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
|
||||||
|
import { defenderRiskTargetsForRecord, scopedRiskAcceptanceStateForTargets, type ScopedRiskAcceptance, type ScopedRiskAcceptanceRevision } from '../../domain/scoped-risk-acceptance';
|
||||||
import type { Review } from '../../domain/review';
|
import type { Review } from '../../domain/review';
|
||||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
import { RelatedFindingRing, useRelatedViewRestore } from '../related-findings/RelatedFindings';
|
import { RelatedFindingRing, useRelatedViewRestore } from '../related-findings/RelatedFindings';
|
||||||
|
|
||||||
export interface RiskRegisterEntry {
|
export interface RiskRegisterEntry {
|
||||||
id: string;
|
id: string;
|
||||||
recordId: string;
|
recordId?: string;
|
||||||
occurrenceId: string;
|
occurrenceId: string;
|
||||||
title: string;
|
title: string;
|
||||||
source: string;
|
source: string;
|
||||||
state: ReturnType<typeof riskAcceptanceState>;
|
state: ReturnType<typeof riskAcceptanceState>;
|
||||||
latest: RiskAcceptanceRevision;
|
latest: RiskAcceptanceRevision | ScopedRiskAcceptanceRevision;
|
||||||
revisions: RiskAcceptanceRevision[];
|
revisions: Array<RiskAcceptanceRevision | ScopedRiskAcceptanceRevision>;
|
||||||
|
device?: string;
|
||||||
|
coveredCves?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceName(record: FindingRecord): string {
|
function sourceName(record: FindingRecord): string {
|
||||||
|
|
@ -24,7 +27,7 @@ function sourceName(record: FindingRecord): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One register row per canonical occurrence, even when several sheets project it. */
|
/** 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>();
|
const entries = new Map<string, RiskRegisterEntry>();
|
||||||
dataset.sheets.forEach(sheet => sheet.analysis.records.forEach(record => {
|
dataset.sheets.forEach(sheet => sheet.analysis.records.forEach(record => {
|
||||||
const value = reviews[record.id]?.riskAcceptance;
|
const value = reviews[record.id]?.riskAcceptance;
|
||||||
|
|
@ -43,6 +46,30 @@ export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record<st
|
||||||
revisions: value!.revisions
|
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 addressed = targets.filter(target => target.device.key === value.target.device.key
|
||||||
|
&& value.target.members.some(member => member.findingDefinitionId === target.definitionId));
|
||||||
|
const currentTarget = addressed.find(target => value.target.members.some(member =>
|
||||||
|
member.capturedOccurrences.some(item => item.occurrenceId === target.occurrenceId))) ?? addressed[0];
|
||||||
|
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: scopedRiskAcceptanceStateForTargets(value, targets, today),
|
||||||
|
latest,
|
||||||
|
revisions: value.revisions,
|
||||||
|
device: value.target.device.displayName,
|
||||||
|
coveredCves
|
||||||
|
});
|
||||||
|
}
|
||||||
return [...entries.values()].sort((left, right) => right.latest.recordedAt.localeCompare(left.latest.recordedAt)
|
return [...entries.values()].sort((left, right) => right.latest.recordedAt.localeCompare(left.latest.recordedAt)
|
||||||
|| left.title.localeCompare(right.title));
|
|| left.title.localeCompare(right.title));
|
||||||
}
|
}
|
||||||
|
|
@ -77,12 +104,13 @@ function DetailGroup({ title, details, fields }: {
|
||||||
</details>;
|
</details>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RiskRegister({ dataset, reviews, onOpenFinding }: {
|
export function RiskRegister({ dataset, reviews, scopedRiskAcceptances = [], onOpenFinding }: {
|
||||||
dataset: ImportedDataset;
|
dataset: ImportedDataset;
|
||||||
reviews: Record<string, Review>;
|
reviews: Record<string, Review>;
|
||||||
|
scopedRiskAcceptances?: ScopedRiskAcceptance[];
|
||||||
onOpenFinding?(recordId: string): void;
|
onOpenFinding?(recordId: string): void;
|
||||||
}) {
|
}) {
|
||||||
const entries = riskRegisterEntries(dataset, reviews);
|
const entries = riskRegisterEntries(dataset, reviews, undefined, scopedRiskAcceptances);
|
||||||
const [requestedId, setRequestedId] = useState('');
|
const [requestedId, setRequestedId] = useState('');
|
||||||
useRelatedViewRestore(() => () => setRequestedId(requestedId));
|
useRelatedViewRestore(() => () => setRequestedId(requestedId));
|
||||||
const recordFor = (entry: RiskRegisterEntry) => dataset.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === entry.recordId);
|
const recordFor = (entry: RiskRegisterEntry) => dataset.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === entry.recordId);
|
||||||
|
|
@ -116,9 +144,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">
|
{!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>
|
<header>
|
||||||
<div><strong>Risk Information</strong><span>{selected.source}</span></div>
|
<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>
|
</header>
|
||||||
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2>{recordFor(selected) && <RelatedFindingRing record={recordFor(selected)!} onGroupOpen={() => onOpenFinding?.(selected.recordId)}/>}</section>
|
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2>{recordFor(selected) && <RelatedFindingRing record={recordFor(selected)!} onGroupOpen={() => onOpenFinding?.(selected.recordId)}/>}</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">
|
<dl className="risk-register-summary">
|
||||||
<DetailField label="Initial risk" value={selected.latest.details.initialRisk} />
|
<DetailField label="Initial risk" value={selected.latest.details.initialRisk} />
|
||||||
<DetailField label="Residual risk" value={selected.latest.details.residualRisk} />
|
<DetailField label="Residual risk" value={selected.latest.details.residualRisk} />
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,8 @@ export function DatasetViewer() {
|
||||||
onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)}
|
onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)}
|
||||||
onReview={patch => updateReview(selected.id, patch)}
|
onReview={patch => updateReview(selected.id, patch)}
|
||||||
onRiskAcceptance={selected.findingOccurrenceId && app.backendStatus === 'connected' ? command => app.recordRiskAcceptance(selected.id, command) : undefined}
|
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}
|
onResults={hasResults(selected) ? () => openResults(selected) : undefined}
|
||||||
resultsOpen={Boolean(resultColor(selected.id))}
|
resultsOpen={Boolean(resultColor(selected.id))}
|
||||||
resultColor={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 { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot';
|
import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot';
|
||||||
import type { ImportedDataset } from '../../domain/dataset';
|
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 { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||||||
|
import { RiskAcceptanceDialog } from '../finding-reader/RiskAcceptanceDialog';
|
||||||
import { defenderModelForDataset } from './defender-dataset-model';
|
import { defenderModelForDataset } from './defender-dataset-model';
|
||||||
|
|
||||||
type DefenderView = 'cves' | 'devices' | 'sites';
|
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>;
|
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));
|
const values = occurrences.filter(item => device.occurrenceIds.includes(item.id));
|
||||||
return <div className="defender-drilldown" id={id}>
|
return <div className="defender-drilldown" id={id}>
|
||||||
<h3>{device.displayName || 'Unnamed device'}</h3>
|
<h3>{device.displayName || 'Unnamed device'}</h3>
|
||||||
<p>{device.ip || 'IP unavailable'} · {device.site || 'Site unavailable'} · Identity: {device.identityProvenance} ({device.identityConfidence})</p>
|
<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">
|
<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>)}
|
{severities.map(item => <span key={item}><strong>{label(item)}</strong><Measure measure={device.severity[item]} /></span>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,12 +54,38 @@ function DeviceCves({ device, occurrences, id }: { device: DefenderDevice; occur
|
||||||
</div>;
|
</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 model = useMemo(() => defenderModelForDataset(dataset), [dataset]);
|
||||||
const [view, setView] = useState<DefenderView>('cves');
|
const [view, setView] = useState<DefenderView>('cves');
|
||||||
const [selectedCve, setSelectedCve] = useState('');
|
const [selectedCve, setSelectedCve] = useState('');
|
||||||
const [selectedDevice, setSelectedDevice] = useState('');
|
const [selectedDevice, setSelectedDevice] = useState('');
|
||||||
const [selectedSite, setSelectedSite] = 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 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>;
|
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}`;
|
const detailId = `defender-device-detail-${index}`;
|
||||||
return <Fragment key={device.id}>
|
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>
|
<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>;
|
</Fragment>;
|
||||||
})}
|
})}
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
</section>}
|
</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">
|
{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>
|
<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>
|
<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 type { Review } from '../../domain/review';
|
||||||
import { defaultReview } from '../../domain/review';
|
import { defaultReview } from '../../domain/review';
|
||||||
import { riskAcceptanceState, type RiskAcceptanceCommand } from '../../domain/risk-acceptance';
|
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 { RiskAcceptanceDialog } from './RiskAcceptanceDialog';
|
||||||
import { PolicySourceEvidence } from './PolicySourceEvidence';
|
import { PolicySourceEvidence } from './PolicySourceEvidence';
|
||||||
import { PolicyBaselineComparison } from './PolicyBaselineComparison';
|
import { PolicyBaselineComparison } from './PolicyBaselineComparison';
|
||||||
|
|
@ -54,6 +60,8 @@ interface Props {
|
||||||
onNext(): void;
|
onNext(): void;
|
||||||
onReview(patch: Partial<Review>): void;
|
onReview(patch: Partial<Review>): void;
|
||||||
onRiskAcceptance?(command: RiskAcceptanceCommand): Promise<void>;
|
onRiskAcceptance?(command: RiskAcceptanceCommand): Promise<void>;
|
||||||
|
scopedRiskAcceptances?: ScopedRiskAcceptance[];
|
||||||
|
onScopedRiskAcceptance?(write: ScopedRiskAcceptanceWrite): Promise<void>;
|
||||||
onResults?(): void;
|
onResults?(): void;
|
||||||
/** Ist das Ergebnisfenster zu diesem Fund gerade geoeffnet? */
|
/** Ist das Ergebnisfenster zu diesem Fund gerade geoeffnet? */
|
||||||
resultsOpen?: boolean;
|
resultsOpen?: boolean;
|
||||||
|
|
@ -411,18 +419,25 @@ function MitreBadgeDisclosure({ fields, search }: { fields: RecordField[]; searc
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 relatedSelection = useRelatedFindings()?.selection;
|
const relatedSelection = useRelatedFindings()?.selection;
|
||||||
const contextClusters = useContextClusterIndex();
|
const contextClusters = useContextClusterIndex();
|
||||||
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
|
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
|
||||||
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
|
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
|
||||||
const [riskAcceptanceFor, setRiskAcceptanceFor] = useState('');
|
const [riskAcceptanceFor, setRiskAcceptanceFor] = useState('');
|
||||||
|
const [riskScope, setRiskScope] = useState('entire');
|
||||||
const linkedResultsOpen = openLinkedResultsFor === record.id;
|
const linkedResultsOpen = openLinkedResultsFor === record.id;
|
||||||
const review = { ...defaultReview(), ...storedReview };
|
const review = { ...defaultReview(), ...storedReview };
|
||||||
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
|
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
|
||||||
const severity = normalizeSeverity(severityField?.value);
|
const severity = normalizeSeverity(severityField?.value);
|
||||||
const hasStructuredResults = linkedSheets.length > 0;
|
const hasStructuredResults = linkedSheets.length > 0;
|
||||||
const resolvedToolId = toolIdForRecord(record, toolId);
|
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 webSearch = findingWebSearch(record, resolvedToolId);
|
||||||
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
|
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
|
||||||
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
|
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
|
||||||
|
|
@ -439,6 +454,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 updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
|
||||||
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
|
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
|
||||||
const regulatoryMapping = useRegulatoryMapping(projectId, regulatoryMappingResultId(record), backendStatus);
|
const regulatoryMapping = useRegulatoryMapping(projectId, regulatoryMappingResultId(record), backendStatus);
|
||||||
|
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 (
|
return (
|
||||||
<article className="finding-reader" data-group-scroll="work-reader">
|
<article className="finding-reader" data-group-scroll="work-reader">
|
||||||
|
|
@ -475,10 +495,10 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
||||||
</div>
|
</div>
|
||||||
{mitre.length > 0 && <MitreBadgeDisclosure key={record.id} fields={mitre} search={search}/>}
|
{mitre.length > 0 && <MitreBadgeDisclosure key={record.id} fields={mitre} search={search}/>}
|
||||||
</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
|
Accepted Risk{riskState
|
||||||
? <small>{riskState}</small>
|
? <small>{riskState}</small>
|
||||||
: ' ?'}
|
: scopedForFinding.length ? <small>{scopedForFinding.length} scoped</small> : ' ?'}
|
||||||
</button>
|
</button>
|
||||||
<div className="reader-heading-actions">
|
<div className="reader-heading-actions">
|
||||||
<FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
|
<FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
|
||||||
|
|
@ -494,7 +514,20 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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="reader-summary">
|
<section className="reader-summary">
|
||||||
<div className="reader-source-rule" data-source-rule-id={sourceRuleId ? true : undefined}>
|
<div className="reader-source-rule" data-source-rule-id={sourceRuleId ? true : undefined}>
|
||||||
{sourceRuleId && <div><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
|
{sourceRuleId && <div><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
import { Dialog } from '../../components/Dialog/Dialog';
|
import { Dialog } from '../../components/Dialog/Dialog';
|
||||||
import { emptyRiskAcceptanceDetails, riskAcceptanceIssues, riskAcceptanceState, type RiskAcceptance,
|
import { emptyRiskAcceptanceDetails, riskAcceptanceIssues, riskAcceptanceState, type RiskAcceptance,
|
||||||
type RiskAcceptanceAction, type RiskAcceptanceCommand, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
|
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 };
|
type Field = { key: keyof RiskAcceptanceDetails; label: string; kind?: 'date' | 'risk' | 'short'; help?: string };
|
||||||
const groups: Array<{ title: string; fields: Field[] }> = [
|
const groups: Array<{ title: string; fields: Field[] }> = [
|
||||||
{ title: 'Scope and business decision', fields: [
|
{ 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: 'scenario', label: 'Risk scenario', help: 'Threat, weakness, possible event and business consequence.' },
|
||||||
{ key: 'businessReason', label: 'Reason for accepting the risk' },
|
{ 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.' },
|
{ 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 {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
occurrenceId?: 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>;
|
onSave?: (command: RiskAcceptanceCommand) => Promise<void>;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +62,7 @@ const actionLabels: Record<RiskAcceptanceAction, string> = {
|
||||||
accept: 'Record acceptance',
|
accept: 'Record acceptance',
|
||||||
revoke: 'Revoke 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 latest = value?.revisions.at(-1);
|
||||||
const [details, setDetails] = useState<RiskAcceptanceDetails>(() => ({ ...emptyRiskAcceptanceDetails(), ...latest?.details }));
|
const [details, setDetails] = useState<RiskAcceptanceDetails>(() => ({ ...emptyRiskAcceptanceDetails(), ...latest?.details }));
|
||||||
const [recordedBy, setRecordedBy] = useState('');
|
const [recordedBy, setRecordedBy] = useState('');
|
||||||
|
|
@ -138,7 +145,14 @@ export function RiskAcceptanceDialog({ title, occurrenceId, value, onSave, onClo
|
||||||
</div>
|
</div>
|
||||||
</section> : <form ref={form} className="risk-acceptance-form" onSubmit={event => { event.preventDefault(); review('draft'); }}>
|
</section> : <form ref={form} className="risk-acceptance-form" onSubmit={event => { event.preventDefault(); review('draft'); }}>
|
||||||
<p className="risk-acceptance-context">{title}</p>
|
<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>
|
<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>}
|
{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>}
|
{!onSave && <p role="status">Open a saved finding with a connected backend to save this record.</p>}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
|
import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
|
||||||
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
import type { RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||||
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||||
|
import type { ScopedRiskAcceptance, ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance';
|
||||||
import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison';
|
import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison';
|
||||||
import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow';
|
import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow';
|
||||||
import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence';
|
import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence';
|
||||||
|
|
@ -136,6 +137,7 @@ export interface ProjectDataExport {
|
||||||
findingOccurrences: FindingOccurrence[];
|
findingOccurrences: FindingOccurrence[];
|
||||||
evidence: EvidenceRecord[];
|
evidence: EvidenceRecord[];
|
||||||
reviews: FindingReview[];
|
reviews: FindingReview[];
|
||||||
|
scopedRiskAcceptances: ScopedRiskAcceptance[];
|
||||||
diagnostics: ImportDiagnostic[];
|
diagnostics: ImportDiagnostic[];
|
||||||
comparisons: StoredComparison[];
|
comparisons: StoredComparison[];
|
||||||
localComparisonRules: LocalComparisonRule[];
|
localComparisonRules: LocalComparisonRule[];
|
||||||
|
|
@ -275,6 +277,8 @@ export interface AssessorRepository {
|
||||||
|
|
||||||
saveReview(review: FindingReview, expectedRevision?: number): Promise<void>;
|
saveReview(review: FindingReview, expectedRevision?: number): Promise<void>;
|
||||||
recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<FindingReview>;
|
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[]>;
|
listReviews(projectId: string): Promise<FindingReview[]>;
|
||||||
|
|
||||||
saveProfileRegistration(profile: ProfileRegistration): Promise<void>;
|
saveProfileRegistration(profile: ProfileRegistration): Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
import { appendRiskAcceptanceReview, assertRiskAcceptanceUnchanged, type RiskAcceptanceCommand } from '../domain/risk-acceptance';
|
||||||
|
import {
|
||||||
|
appendScopedRiskAcceptance,
|
||||||
|
scopedRiskTargetFromOccurrences,
|
||||||
|
withCapturedScopeStatus,
|
||||||
|
type ScopedRiskAcceptance,
|
||||||
|
type ScopedRiskAcceptanceTarget,
|
||||||
|
type ScopedRiskAcceptanceWrite
|
||||||
|
} from '../domain/scoped-risk-acceptance';
|
||||||
import type {
|
import type {
|
||||||
Assessment,
|
Assessment,
|
||||||
AssessmentProject,
|
AssessmentProject,
|
||||||
|
|
@ -110,6 +118,7 @@ interface LocalRepositoryState {
|
||||||
regulatoryMappingDecisions: RegulatoryMappingDecision[];
|
regulatoryMappingDecisions: RegulatoryMappingDecision[];
|
||||||
evidence: EvidenceRecord[];
|
evidence: EvidenceRecord[];
|
||||||
reviews: FindingReview[];
|
reviews: FindingReview[];
|
||||||
|
scopedRiskAcceptances: ScopedRiskAcceptance[];
|
||||||
diagnostics: ImportDiagnostic[];
|
diagnostics: ImportDiagnostic[];
|
||||||
profileRegistrations: ProfileRegistration[];
|
profileRegistrations: ProfileRegistration[];
|
||||||
comparisons: StoredComparison[];
|
comparisons: StoredComparison[];
|
||||||
|
|
@ -148,6 +157,7 @@ const emptyState = (): LocalRepositoryState => ({
|
||||||
regulatoryMappingDecisions: [],
|
regulatoryMappingDecisions: [],
|
||||||
evidence: [],
|
evidence: [],
|
||||||
reviews: [],
|
reviews: [],
|
||||||
|
scopedRiskAcceptances: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
profileRegistrations: [],
|
profileRegistrations: [],
|
||||||
comparisons: [],
|
comparisons: [],
|
||||||
|
|
@ -1522,6 +1532,62 @@ export class LocalAssessorRepository implements AssessorRepository {
|
||||||
return clone(review);
|
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 this.scopedWithStatus(projectId, [clone(saved)])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listScopedRiskAcceptances(projectId: string): Promise<ScopedRiskAcceptance[]> {
|
||||||
|
if (!this.state.projects.some(item => item.id === projectId)) throw new RepositoryNotFoundError('Project is not available.');
|
||||||
|
return this.scopedWithStatus(projectId, this.state.scopedRiskAcceptances.filter(item => item.projectId === projectId).map(clone));
|
||||||
|
}
|
||||||
|
|
||||||
|
private scopedWithStatus(projectId: string, values: ScopedRiskAcceptance[]): ScopedRiskAcceptance[] {
|
||||||
|
const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id));
|
||||||
|
const current = new Set(this.state.scanRuns
|
||||||
|
.filter(item => assessmentIds.has(item.assessmentId) && !item.supersededByScanRunId).map(item => item.id));
|
||||||
|
return values.map(value => withCapturedScopeStatus(value, current));
|
||||||
|
}
|
||||||
|
|
||||||
async listReviews(projectId: string): Promise<FindingReview[]> {
|
async listReviews(projectId: string): Promise<FindingReview[]> {
|
||||||
const assessmentIds = new Set(this.state.assessments.filter(item => item.projectId === projectId).map(item => item.id));
|
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));
|
const runIds = new Set(this.state.scanRuns.filter(item => assessmentIds.has(item.assessmentId)).map(item => item.id));
|
||||||
|
|
@ -1627,6 +1693,7 @@ export class LocalAssessorRepository implements AssessorRepository {
|
||||||
findingOccurrences: byId(findingOccurrences),
|
findingOccurrences: byId(findingOccurrences),
|
||||||
evidence: byId(this.state.evidence.filter(item => occurrenceIds.has(item.occurrenceId))),
|
evidence: byId(this.state.evidence.filter(item => occurrenceIds.has(item.occurrenceId))),
|
||||||
reviews: await this.listReviews(projectId),
|
reviews: await this.listReviews(projectId),
|
||||||
|
scopedRiskAcceptances: await this.listScopedRiskAcceptances(projectId),
|
||||||
diagnostics: byId(this.state.diagnostics.filter(item => fileIds.has(item.importFileId))),
|
diagnostics: byId(this.state.diagnostics.filter(item => fileIds.has(item.importFileId))),
|
||||||
comparisons: byId(this.state.comparisons.filter(item => item.projectId === projectId)),
|
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)),
|
localComparisonRules: this.state.localComparisonRules.filter(item => item.projectId === projectId).map(item => item.rule).sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
|
@ -1832,6 +1899,7 @@ export class LocalAssessorRepository implements AssessorRepository {
|
||||||
next.policyEvaluations = next.policyEvaluations.filter(item => item.run.projectId !== projectId);
|
next.policyEvaluations = next.policyEvaluations.filter(item => item.run.projectId !== projectId);
|
||||||
next.aiOverviewRuns = next.aiOverviewRuns.filter(item => item.projectId !== projectId);
|
next.aiOverviewRuns = next.aiOverviewRuns.filter(item => item.projectId !== projectId);
|
||||||
next.regulatoryMappingBatchRuns = next.regulatoryMappingBatchRuns.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.
|
// Regulatory mappings and decisions are global and survive project deletion.
|
||||||
this.replaceState(next);
|
this.replaceState(next);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,14 @@ mark, .search-highlight {
|
||||||
.risk-acceptance-form { display: grid; gap: 16px; }
|
.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-form p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }
|
||||||
.risk-acceptance-context { font-weight: var(--fw-semibold); }
|
.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-note { color: var(--muted); }
|
||||||
.risk-acceptance-fields { min-width: 0; display: grid; gap: 24px; margin: 0; padding: 0; border: 0; }
|
.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); }
|
.risk-acceptance-fields h3, .risk-acceptance-history h3 { margin: 0 0 12px; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
||||||
.client-risk-register-detail > header span,
|
.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); }
|
.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); }
|
.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-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 { 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; }
|
.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 h3 { font-size: var(--type-detail-title-size); }
|
||||||
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
||||||
.defender-drilldown p { margin: 0; font-size: var(--type-body-size); line-height: 1.55; }
|
.defender-drilldown 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 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 summary { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||||
.defender-drilldown details > .defender-table, .defender-drilldown details > .defender-drilldown { margin-top: var(--space-3); }
|
.defender-drilldown details > .defender-table, .defender-drilldown details > .defender-drilldown { margin-top: var(--space-3); }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { FindingOccurrence, JsonValue } from '../../src/domain/canonical-model';
|
||||||
|
import {
|
||||||
|
appendScopedRiskAcceptance,
|
||||||
|
defenderRiskCoverageDecisions,
|
||||||
|
defenderRiskTargetsForOccurrence,
|
||||||
|
defenderRiskTargetsForOccurrences,
|
||||||
|
scopedAcceptanceCovers,
|
||||||
|
scopedRiskAcceptanceState,
|
||||||
|
scopedRiskAcceptanceStateForTargets,
|
||||||
|
scopedRiskTargetFromOccurrences,
|
||||||
|
withCapturedScopeStatus
|
||||||
|
} 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('covers only the captured occurrence and needs review on a later import, like a whole-finding acceptance', () => {
|
||||||
|
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];
|
||||||
|
|
||||||
|
const captured = defenderRiskTargetsForOccurrence(original)[0];
|
||||||
|
|
||||||
|
expect(scopedAcceptanceCovers(acceptance, captured, '2026-09-21')).toBe(true);
|
||||||
|
expect(scopedRiskAcceptanceStateForTargets(acceptance, [captured], '2026-09-21')).toBe('Accepted');
|
||||||
|
// Same device and CVE through a replacement occurrence: kept, but never silently re-attached.
|
||||||
|
expect(scopedAcceptanceCovers(acceptance, laterSame, '2026-09-21')).toBe(false);
|
||||||
|
expect(scopedRiskAcceptanceStateForTargets(acceptance, [laterSame], '2026-09-21')).toBe('Review required');
|
||||||
|
expect(scopedAcceptanceCovers(acceptance, laterNewCve, '2026-09-21')).toBe(false);
|
||||||
|
expect(scopedAcceptanceCovers(acceptance, changedDevice, '2026-09-21')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a decision whose captured scan run was replaced as Review required without coverage', () => {
|
||||||
|
const original = occurrence('occurrence-old', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
|
||||||
|
const target = scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-a', [original], now);
|
||||||
|
const acceptance = appendScopedRiskAcceptance(undefined, 'project-1', 'pair-1', target, riskCommand('accept', '2026-09-21'), now);
|
||||||
|
const captured = defenderRiskTargetsForOccurrence(original)[0];
|
||||||
|
|
||||||
|
const current = withCapturedScopeStatus(acceptance, new Set(['run-1']));
|
||||||
|
expect(current.capturedScopeReplaced).toBe(false);
|
||||||
|
expect(scopedRiskAcceptanceState(current, '2026-09-21')).toBe('Accepted');
|
||||||
|
|
||||||
|
const replaced = withCapturedScopeStatus(acceptance, new Set(['run-2']));
|
||||||
|
expect(replaced.capturedScopeReplaced).toBe(true);
|
||||||
|
expect(scopedRiskAcceptanceState(replaced, '2026-09-21')).toBe('Review required');
|
||||||
|
expect(scopedAcceptanceCovers(replaced, captured, '2026-09-21')).toBe(false);
|
||||||
|
expect(defenderRiskCoverageDecisions(captured, undefined, [replaced], '2026-09-21')).toEqual([]);
|
||||||
|
// The history is kept and a revoked decision stays revoked.
|
||||||
|
expect(replaced.revisions).toEqual(acceptance.revisions);
|
||||||
|
const revoked = withCapturedScopeStatus(
|
||||||
|
appendScopedRiskAcceptance(acceptance, 'project-1', 'pair-1', target, riskCommand('revoke', '2026-09-21'), now), new Set(['run-2']));
|
||||||
|
expect(scopedRiskAcceptanceState(revoked, '2026-09-21')).toBe('Revoked');
|
||||||
|
// The marker is derived on read and never becomes part of the stored history.
|
||||||
|
expect(appendScopedRiskAcceptance(replaced, 'project-1', 'pair-1', target, riskCommand('draft', '2026-09-21'), now))
|
||||||
|
.not.toHaveProperty('capturedScopeReplaced');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -31,7 +31,8 @@ describe('dashboard reference layout', () => {
|
||||||
expect(component).not.toContain('className="client-assessment-gaps"');
|
expect(component).not.toContain('className="client-assessment-gaps"');
|
||||||
expect(component).toContain('<span>Gaps</span>');
|
expect(component).toContain('<span>Gaps</span>');
|
||||||
expect(component).toContain('<EnvironmentResultsButton title="Overall Score">');
|
expect(component).toContain('<EnvironmentResultsButton title="Overall Score">');
|
||||||
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(styles).toMatch(/\.dashboard-page\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||||
expect(component).toContain('className={`client-posture tone-${overallTone}`}');
|
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);
|
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(component).toContain('<span>Source:</span><strong>Microsoft Defender for IoT</strong>');
|
||||||
expect(dashboard).toContain("[['overview', 'Overview'] as const]");
|
expect(dashboard).toContain("[['overview', 'Overview'] as const]");
|
||||||
expect(dashboard).toContain("[['defender', 'Microsoft Defender for IoT'] as const]");
|
expect(dashboard).toContain("[['defender', 'Microsoft Defender for IoT'] as const]");
|
||||||
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('padding: var(--panel-inset)');
|
||||||
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
|
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
|
||||||
expect(styles).toContain('background: var(--tile-surface)');
|
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-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])]
|
['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 {
|
return {
|
||||||
fileName: 'Synthetic project', fileSize: 1, lastModified: 0, format: 'Project · Microsoft Defender for IoT', rawText: '',
|
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',
|
profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', scanTool: 'Microsoft Defender for IoT',
|
||||||
sheets: [
|
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' })
|
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());
|
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());
|
await act(async () => button.click());
|
||||||
}
|
}
|
||||||
describe('Accepted Risk dialog', () => {
|
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 () => {
|
it('opens from the finding header and blocks incomplete acceptance without changing the review', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const container = document.createElement('div'); document.body.append(container);
|
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 { ImportedDataset } from '../../src/domain/dataset';
|
||||||
import type { FindingRecord } from '../../src/domain/finding';
|
import type { FindingRecord } from '../../src/domain/finding';
|
||||||
import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance';
|
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 { defaultReview, type Review } from '../../src/domain/review';
|
||||||
import { RiskRegister, riskRegisterEntries } from '../../src/features/dashboard/RiskRegister';
|
import { RiskRegister, riskRegisterEntries } from '../../src/features/dashboard/RiskRegister';
|
||||||
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
||||||
|
|
@ -25,6 +26,27 @@ function dataset(records: FindingRecord[], duplicate: FindingRecord): ImportedDa
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Client Info risk register', () => {
|
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'] });
|
||||||
|
// After a reimport replaced the captured scan run, the decision stays listed and needs review.
|
||||||
|
const replaced = riskRegisterEntries(dataset([], record('duplicate', 'none', 'Unused', 'Defender')), {}, today, [{ ...acceptance, capturedScopeReplaced: true }]);
|
||||||
|
expect(replaced).toHaveLength(1);
|
||||||
|
expect(replaced[0]).toMatchObject({ id: 'snapshot-1', state: 'Review required', revisions: acceptance.revisions });
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates projected occurrences and keeps their current governance state', () => {
|
it('deduplicates projected occurrences and keeps their current governance state', () => {
|
||||||
const first = record('record-1', 'occurrence-1', 'Synthetic identity risk', 'Purple Knight');
|
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');
|
const duplicate = record('record-1-copy', 'occurrence-1', 'Synthetic identity risk', 'Purple Knight');
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { compareScanRuns } from '../../src/comparison/comparison-engine';
|
||||||
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
||||||
import { RepositoryConflictError, RepositoryValidationError } from '../../src/persistence/repository-errors';
|
import { RepositoryConflictError, RepositoryValidationError } from '../../src/persistence/repository-errors';
|
||||||
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
||||||
|
import { defenderRiskTargetsForOccurrences, scopedAcceptanceCovers, scopedRiskAcceptanceState } from '../../src/domain/scoped-risk-acceptance';
|
||||||
import { riskAcceptanceState } from '../../src/domain/risk-acceptance';
|
import { riskAcceptanceState } from '../../src/domain/risk-acceptance';
|
||||||
|
|
||||||
const timestamp = '2026-07-17T08:00:00.000Z';
|
const timestamp = '2026-07-17T08:00:00.000Z';
|
||||||
|
|
@ -91,6 +92,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> {
|
async function repositoryWithProject(): Promise<LocalAssessorRepository> {
|
||||||
const repository = new LocalAssessorRepository();
|
const repository = new LocalAssessorRepository();
|
||||||
await repository.saveProject(project);
|
await repository.saveProject(project);
|
||||||
|
|
@ -127,6 +159,91 @@ function review(revision: number, status: FindingReview['workflowStatus'] = 'ope
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LocalAssessorRepository contract', () => {
|
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('keeps a device-scoped decision after a reimport and shows it as Review required', async () => {
|
||||||
|
const repository = await repositoryWithProject();
|
||||||
|
const original = defenderRun(1);
|
||||||
|
await commitRun(repository, original, defenderImportData(original, [
|
||||||
|
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-cve-1', cve: 'CVE-2026-1001', devices: ['DEV-A'] }
|
||||||
|
]));
|
||||||
|
const accepted = await repository.recordScopedRiskAcceptance(project.id, {
|
||||||
|
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:dev-a' },
|
||||||
|
command: riskCommand('accept'), expectedRevision: 0
|
||||||
|
});
|
||||||
|
expect(accepted.capturedScopeReplaced).toBe(false);
|
||||||
|
|
||||||
|
// Reprocessing the identical report with a changed identity rule yields another device key.
|
||||||
|
const replacement = { ...defenderRun(2), importedAt: '2026-07-18T08:00:00.000Z' };
|
||||||
|
const replacementData = defenderImportData(replacement, [
|
||||||
|
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-cve-1-reimport', cve: 'CVE-2026-1001', devices: ['DEV-C'] }
|
||||||
|
]);
|
||||||
|
// A reimport must replace the cryptographically identical report.
|
||||||
|
replacementData.importFiles[0].sha256 = '1'.repeat(64);
|
||||||
|
await repository.commitCanonicalImport({
|
||||||
|
sessionId: 'canonical-defender-reimport', replacesScanRunId: original.id,
|
||||||
|
model: { schemaVersion: 1, project, assessment, scanRuns: [replacement], reviews: [], ...replacementData },
|
||||||
|
fingerprints: replacementData.importFiles.map(file => ({ sha256: file.sha256!, fileName: file.fileName, sizeBytes: 100, lastModified: 0 }))
|
||||||
|
});
|
||||||
|
|
||||||
|
const [kept] = await repository.listScopedRiskAcceptances(project.id);
|
||||||
|
expect(kept).toMatchObject({ id: accepted.id, capturedScopeReplaced: true });
|
||||||
|
expect(kept.revisions).toEqual(accepted.revisions);
|
||||||
|
expect(kept.target).toEqual(accepted.target);
|
||||||
|
expect(scopedRiskAcceptanceState(kept)).toBe('Review required');
|
||||||
|
const model = (await repository.loadAssessmentModel(assessment.id))!;
|
||||||
|
const targets = defenderRiskTargetsForOccurrences(model.findingOccurrences);
|
||||||
|
expect(targets.map(target => target.device.key)).toEqual(['device-id:dev-c']);
|
||||||
|
expect(targets.some(target => scopedAcceptanceCovers(kept, target))).toBe(false);
|
||||||
|
expect((await repository.exportProject(project.id)).scopedRiskAcceptances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('stores risk decisions atomically with optimistic concurrency, export and reimport history', async () => {
|
it('stores risk decisions atomically with optimistic concurrency, export and reimport history', async () => {
|
||||||
const repository = await repositoryWithProject();
|
const repository = await repositoryWithProject();
|
||||||
await commitRun(repository, scanRun(1));
|
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);
|
||||||
|
});
|
||||||
|
|
@ -15,7 +15,7 @@ const migrationPath = resolve(
|
||||||
|
|
||||||
describe('PostgreSQL vulnerability finding result type migration', () => {
|
describe('PostgreSQL vulnerability finding result type migration', () => {
|
||||||
it('registers migration 027 after regulatory mapping', () => {
|
it('registers migration 027 after regulatory mapping', () => {
|
||||||
expect(databaseMigrations.at(-1)).toMatchObject({
|
expect(databaseMigrations.find(item => item.version === '027')).toMatchObject({
|
||||||
version: '027',
|
version: '027',
|
||||||
name: 'vulnerability-finding-result-type'
|
name: 'vulnerability-finding-result-type'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,51 @@ describe('batch import HTTP workflow', () => {
|
||||||
const reloaded = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
const reloaded = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
||||||
expect(reloaded).toEqual(model);
|
expect(reloaded).toEqual(model);
|
||||||
});
|
});
|
||||||
|
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 () => {
|
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 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: ';' };
|
const file = { fileName: 'synthetic.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue