From 2eafefc5a15b56aa306bdd37c1bf620af64ca58e Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 2 Oct 2026 13:38:34 +0200 Subject: [PATCH] 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. --- docs/IMPLEMENTATION_PLAN.md | 1 + docs/RISK_ACCEPTANCE.md | 18 +- server/assessor-http-server.ts | 18 + server/postgres/migration-catalog.ts | 4 +- .../028_scoped_risk_acceptances.sql | 16 + .../postgres/postgres-assessor-repository.ts | 84 ++++ src/api/assessor-api-client.ts | 7 + src/app/AppProvider.tsx | 38 +- src/app/app-state.ts | 3 + src/domain/scoped-risk-acceptance.ts | 377 ++++++++++++++++++ src/features/dashboard/DashboardPanel.tsx | 6 +- src/features/dashboard/RiskRegister.tsx | 43 +- src/features/dataset-viewer/DatasetViewer.tsx | 2 + .../DefenderVulnerabilityPanel.tsx | 73 +++- src/features/finding-reader/FindingReader.tsx | 41 +- .../finding-reader/RiskAcceptanceDialog.tsx | 22 +- src/persistence/assessor-repository.ts | 4 + src/persistence/local-assessor-repository.ts | 68 ++++ src/styles/components.css | 8 + src/styles/datahub.css | 3 + src/styles/defender-for-iot.css | 2 + tests/domain/scoped-risk-acceptance.test.ts | 175 ++++++++ tests/features/dashboard-layout.test.ts | 3 +- tests/features/defender-dashboard.test.ts | 4 +- .../defender-inline-drilldown.test.tsx | 33 +- .../features/risk-acceptance-dialog.test.tsx | 26 ++ tests/features/risk-register.test.tsx | 22 + tests/persistence/assessor-repository.test.ts | 117 ++++++ .../scoped-risk-acceptance-schema.test.ts | 14 + ...scoped-risk-acceptance.integration.test.ts | 91 +++++ .../vulnerability-finding-schema.test.ts | 2 +- tests/server/import-batch-http.test.ts | 45 +++ 32 files changed, 1328 insertions(+), 42 deletions(-) create mode 100644 server/postgres/migrations/028_scoped_risk_acceptances.sql create mode 100644 src/domain/scoped-risk-acceptance.ts create mode 100644 tests/domain/scoped-risk-acceptance.test.ts create mode 100644 tests/postgres/scoped-risk-acceptance-schema.test.ts create mode 100644 tests/postgres/scoped-risk-acceptance.integration.test.ts diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index cd6f1c5..d7e5b7a 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4231,6 +4231,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client | 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. | | 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. | | 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. | +| 2026-10-02 | Phasen 7/13A: `d9ff6a8` (geraetebezogene Risikoakzeptanz fuer Defender) angepasst uebernommen. Neben der bestehenden Akzeptanz des ganzen Findings gibt es die Ziele `device-finding` (ein Device mal eine CVE) und `device-snapshot` (alle aktuellen CVEs eines Device) mit unveraenderlichem Zielabbild und append-only Historie. Migration `028_scoped_risk_acceptances.sql` legt die projektgebundene Tabelle `assessor_scoped_risk_acceptances` an (Cascade bei Projektloeschung); beide Repositories, `GET`/`POST /api/v1/projects/:id/risk-acceptances/scoped`, App-State, Defender-Panel (Abdeckungszeile, deckende Entscheidungen, `Accept all current findings`), Finding Reader/Dialog und das Accepted-Risks-Register sind erweitert. Nutzerentscheidung 2026-10-02, abweichend vom Original: (1) Die Zaehlung `current` im Register bleibt bei der Basisregel `Accepted`, `Scheduled`, `Review due`, `Review required`. (2) Geraetebezogene Entscheidungen folgen der Regel fuer ganze Findings: sie decken nur die erfassten Occurrences; nach einem Reimport oder entfernten Scan-Lauf sowie bei einer nicht erfassten Occurrence desselben Device und derselben CVE bleibt die Entscheidung mit vollstaendiger Historie erhalten, zeigt `Review required` und deckt nichts. Der Marker `capturedScopeReplaced` wird bei jedem Lesen aus den aktuellen Scan-Laeufen abgeleitet und nie gespeichert. Offen: Ersetzte geraetebezogene Entscheidungen fliessen nicht in die Reimport-Transferzaehlung je Datei und damit nicht in den Data-Hub-Verarbeitungsstatus ein; das Defender-Panel listet nur deckende Entscheidungen. Neue Regressionen: Domain (erfasste gegen ersetzte Occurrence, Marker, Widerruf, keine Persistenz des Markers), lokales Repository (Reimport desselben Berichts mit geaenderter Device-Identitaet) und Register. Der opt-in PostgreSQL-Integrationstest lief nicht; der PostgreSQL-Lesepfad des Markers ist damit nur typgeprueft. Vor der Migration wurde das verifizierte Backup `backups/assessor-pre-028-20261002-132810.dump` erstellt. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.908 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/7: Defender-Teile von `b438f25` uebernommen (nach `c7c716c`, weil der Commit zunaechst nur als NVD-Korrektur eingeordnet war). CVE-, Device- und Site-Tabelle des Defender-Panels klappen ihre Details direkt unter der angeklickten Zeile auf und wieder zu (Dreieck-Schalter, `aria-expanded`/`aria-controls`); die CVE-Tabelle fuehrt die Beschreibung nur noch im Detail. Der Extractor gibt die Tabelle `Affected Devices` als `Name`, `IP Address`, `Scanner`, `Site` aus. Abweichend vom Original nutzt die aufgeklappte Zeile nach Nutzerentscheidung die blaue Tabellenzeilen-Markierung (`--table-row-marking`, `--table-row-marking-surface`) statt Gelb, und der Drilldown bleibt auf den gemeinsamen Kachelrollen. Nicht uebernommen: die `baseSeverity`-Korrektur im NVD-Client samt Test (CVE-Anreicherung ausgelassen) und der `.gitignore`-Eintrag. Kein Profilversionssprung wie im Original; die Tabellenspalten liegen bereits unter dem danach uebernommenen Profil 1.2.0, die lokale Datenbank enthaelt keine Defender-Importe. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.896 bestandenen und 17 uebersprungenen Tests (ein lastbedingter Einzelausfall in `node-import-engine.test.ts` im ersten Lauf, allein und im Wiederholungslauf bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 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. | diff --git a/docs/RISK_ACCEPTANCE.md b/docs/RISK_ACCEPTANCE.md index 8174581..6e915d5 100644 --- a/docs/RISK_ACCEPTANCE.md +++ b/docs/RISK_ACCEPTANCE.md @@ -1,6 +1,6 @@ # Accepted Risk: decision record and storage contract -Status: implemented local documentation workflow; authenticated organizational approval remains a server-readiness requirement. +Status: implemented whole-finding and Defender device-scoped application workflows; authenticated organizational approval remains a phase-14 server-readiness requirement. ## Purpose and basis @@ -22,6 +22,8 @@ User decision, 2026-09-10: no existing organizational approval rules; provide a - Applicable policy, regulatory and contractual considerations and any consulted specialists or restrictions. - Additional evidence references, recorder name, revision reason, server recording timestamp and complete prior decision snapshots. +For Microsoft Defender for IoT, the user-entered scope narrative is separate from an immutable system target. A `device-finding` target captures one exact stable device identity and one canonical CVE/finding definition. A `device-snapshot` target captures one exact stable device identity and every detailed actionable CVE in the selected active scan-run scope at decision time. Each covered member retains its canonical definition/CVE plus occurrence, scan-run, import-file, source-table and row provenance. + Evidence references are stored as inert text. The form neither uploads documents nor verifies their contents, signatures or availability. Source finding values and scanner provenance remain in the existing canonical model. ## States and rules @@ -32,19 +34,25 @@ Displayed states are derived at read/render time: Not recorded, Draft, Scheduled The existing review workflow (including the historical `Actionable` → `accepted` mapping) is separate and is not repurposed for risk acceptance. Ordinary review edits preserve the structured record. Accepted Risk does not automatically alter bookmarks, remediation status or technical dashboard numbers. +Effective Defender device/CVE coverage is the union of independent valid decisions. A current whole-finding acceptance covers every affected device for that CVE; a device-finding decision covers one device/CVE pair; a device snapshot covers only its captured CVE definitions. `Accepted` and visibly due `Review due` decisions provide current coverage. Draft, Scheduled, Expired, Revoked and Review required decisions do not. Revoking one overlapping decision never revokes another. + ## Implemented persistence -The authoritative record is `FindingReview.riskAcceptance`, an additive version-1 JSON object stored in the existing `assessor_reviews.data` JSONB column. It contains an append-only `revisions` array. Each revision contains action, complete details, sequential revision number, recorder, reason, original target occurrence ID, server-generated timestamp and `approvalIdentityVerified: false`. +The authoritative whole-finding record remains `FindingReview.riskAcceptance`, an additive version-1 JSON object stored in the existing `assessor_reviews.data` JSONB column. It contains an append-only `revisions` array. Each revision contains action, complete details, sequential revision number, recorder, reason, original target occurrence ID, server-generated timestamp and `approvalIdentityVerified: false`. -No SQL migration is needed for this implementation. The canonical local datastore remains Docker project `assessor-2_0`, container `assessor-2_0-postgres-1`, database `assessor`, volume `assessor-2_0_assessor-postgres-data`, `127.0.0.1:5432`. The browser never accesses PostgreSQL directly. +Migration 028 adds the project-owned `assessor_scoped_risk_acceptances` table for device-scoped histories. Its indexed immutable target snapshot is stored with revision, timestamps and the complete append-only decision history in JSONB; project deletion cascades. Existing `FindingReview.riskAcceptance` records are unchanged and require no migration. Before applying 028 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. +Scoped reads and writes use `GET` and `POST /api/v1/projects/:projectId/risk-acceptances/scoped`. Creation accepts only a device-finding occurrence/device reference or a device-snapshot scan-run/device reference. The server verifies project membership, reconstructs targets through the shared Defender identity model, rejects unresolved or ambiguous identities, and derives the complete nonempty snapshot membership itself. Existing scoped decisions accept only revision commands: their target cannot be replaced. PostgreSQL row locks and optimistic revisions make grouped accept/revoke changes atomic. Ordinary review endpoints never write the scoped table. Project export includes both legacy and scoped histories, and application state loads scoped histories with the project workspace. + The UI reports successful storage only after the backend acknowledges the write. Disconnected/unsaved findings cannot record acceptance. Failed writes retain the dialog entries. Existing browser review caches and project snapshots can retain a copy; they are not the authority for acceptance. Exports include the complete structured review record. Existing project/scan deletion policies still apply: this is not an independent legal-hold archive. ## Reimport and retention -The existing stable-identity review transfer preserves the complete object and all original revision targets. It never matches by title or row position. If a replacement occurrence differs from the recorded target, the preserved acceptance displays Review required. A new decision can explicitly cover the replacement after reassessment. Failed or ambiguous matches retain the original review according to the reimport policy. +The existing stable-identity review transfer preserves the complete whole-finding object and all original revision targets. It never matches by title or row position. If a replacement occurrence differs from the recorded target, the preserved acceptance displays Review required. A new decision can explicitly cover the replacement after reassessment. Failed or ambiguous matches retain the original review according to the reimport policy. + +Scoped records are project-owned and do not transfer between projects. 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. @@ -55,7 +63,7 @@ The current local-only application has no authenticated approver identity, organ Before organization-wide approval in the browser/server target, add: 1. SSO-bound actors and client-scoped authorization; configurable risk-owner/approver roles, thresholds, separation of duties and delegation rules. -2. Dedicated `risk_acceptances`, `risk_acceptance_revisions`, `risk_acceptance_approvals` and `risk_acceptance_evidence` tables with immutable event IDs, project/occurrence foreign keys, actor IDs and server timestamps. Migrate current JSON history without replacing historical declared identities with verified identities. +2. Phase-14 governance tables for authenticated actors, immutable approval events and evidence records. Migration 028 is only the current project-owned scoped decision snapshot/history store; it does not provide authenticated actors, signing or legal retention. Any future migration must preserve both legacy review JSON and scoped histories without replacing historical declared identities with verified identities. 3. Evidence references with content hashes, access controls, immutable versions and an organization-approved retention/legal-hold policy. Do not add external storage or uploads without a security-model update. 4. Transactional approval/revocation endpoints, tamper-evident audit retention, server-side due/expiry queries and an explicitly configured notification/monitoring workflow. 5. Tests against a dedicated approved integration environment for concurrent approvals, permissions, clock boundaries, evidence retention and migration round-trips. An approved persistence migration requires a verified backup of the canonical datastore first. diff --git a/server/assessor-http-server.ts b/server/assessor-http-server.ts index 92254e6..967d5f9 100644 --- a/server/assessor-http-server.ts +++ b/server/assessor-http-server.ts @@ -1,4 +1,5 @@ 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 { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; @@ -1279,6 +1280,23 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se await repository.saveReview(body.review, body.expectedRevision); status = 200; sendJson(response, status, { review: body.review, repositoryRevision: repository.revision }, requestId); return; } + if (segments.length === 4 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && segments[3] === 'scoped' && method === 'GET') { + status = 200; sendJson(response, status, await repository.listScopedRiskAcceptances(segments[1]), requestId); return; + } + if (segments.length === 4 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && segments[3] === 'scoped' && method === 'POST') { + const body = await readJson(request, maximumBodyBytes); + const target = body?.target; + const invalidTarget = target && (typeof target.deviceKey !== 'string' || !target.deviceKey.trim() + || (target.kind === 'device-finding' ? typeof target.occurrenceId !== 'string' || !target.occurrenceId.trim() + : target.kind === 'device-snapshot' ? !Array.isArray(target.scanRunIds) || !target.scanRunIds.length || target.scanRunIds.some(id => typeof id !== 'string' || !id.trim()) + : true)); + if (!body || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 || !body.command + || (body.acceptanceId !== undefined && (typeof body.acceptanceId !== 'string' || !body.acceptanceId.trim())) || invalidTarget) { + throw new HttpError(400, 'invalid_scoped_risk_acceptance', 'A scoped risk command and expected revision are required.'); + } + const acceptance = await repository.recordScopedRiskAcceptance(segments[1], body); + status = 200; sendJson(response, status, { acceptance, repositoryRevision: repository.revision }, requestId); return; + } if (segments.length === 3 && segments[0] === 'projects' && segments[2] === 'risk-acceptances' && method === 'POST') { const body = await readJson<{ occurrenceId: string; command: RiskAcceptanceCommand; expectedRevision: number }>(request, maximumBodyBytes); if (typeof body.occurrenceId !== 'string' || !body.occurrenceId || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) throw new HttpError(400, 'invalid_risk_acceptance', 'Finding and expected review revision are required.'); diff --git a/server/postgres/migration-catalog.ts b/server/postgres/migration-catalog.ts index 01def18..6c07948 100644 --- a/server/postgres/migration-catalog.ts +++ b/server/postgres/migration-catalog.ts @@ -23,6 +23,7 @@ import resultDbMaterialization from './migrations/024_result_db_materialization. import aiOverviewRuns from './migrations/025_ai_overview_runs.sql?raw'; import regulatoryMapping from './migrations/026_regulatory_mapping.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 { version: string; @@ -55,5 +56,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ { version: '024', name: 'result-db-materialization', sql: resultDbMaterialization }, { version: '025', name: 'ai-overview-runs', sql: aiOverviewRuns }, { 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 } ]; diff --git a/server/postgres/migrations/028_scoped_risk_acceptances.sql b/server/postgres/migrations/028_scoped_risk_acceptances.sql new file mode 100644 index 0000000..995a479 --- /dev/null +++ b/server/postgres/migrations/028_scoped_risk_acceptances.sql @@ -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.'; diff --git a/server/postgres/postgres-assessor-repository.ts b/server/postgres/postgres-assessor-repository.ts index e227c5e..e3d3a3c 100644 --- a/server/postgres/postgres-assessor-repository.ts +++ b/server/postgres/postgres-assessor-repository.ts @@ -1,4 +1,13 @@ 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 { Assessment, @@ -385,6 +394,7 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record = { assessor_finding_occurrence_categories: 'result', assessor_evidence: 'result', assessor_reviews: 'governance', + assessor_scoped_risk_acceptances: 'governance', assessor_comparisons: 'governance', assessor_projects: 'core', assessor_assessments: 'core', @@ -2713,6 +2723,79 @@ export class PostgresAssessorRepository implements AssessorRepository { }); } + async recordScopedRiskAcceptance(projectId: string, write: ScopedRiskAcceptanceWrite): Promise { + return this.mutate(async client => { + const existing = write.acceptanceId + ? await client.query>(`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 | undefined; + if (write.target.kind === 'device-finding') { + const result = await client.query>(`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>(`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>(`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 { + return this.scopedWithStatus(this.pool, projectId, dataRows(await this.pool.query>( + '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 { + 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 { return dataRows(await this.pool.query>(` 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), evidence: models.flatMap(model => model.evidence), reviews: await this.listReviews(projectId), + scopedRiskAcceptances: await this.listScopedRiskAcceptances(projectId), diagnostics: models.flatMap(model => model.diagnostics), comparisons: dataRows(await this.pool.query>( 'SELECT data FROM assessor_comparisons WHERE project_id = $1 ORDER BY id', [projectId] diff --git a/src/api/assessor-api-client.ts b/src/api/assessor-api-client.ts index 48d3134..831e980 100644 --- a/src/api/assessor-api-client.ts +++ b/src/api/assessor-api-client.ts @@ -1,6 +1,7 @@ import { trackActivity } from '../platform/activity-store'; import { requestActivity } from './request-activity'; import type { RiskAcceptanceCommand } from '../domain/risk-acceptance'; +import type { ScopedRiskAcceptance, ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance'; import type { Assessment, AssessmentProject, CanonicalAssessmentModel, FindingReview, ImportFileRecord } from '../domain/canonical-model'; import type { UnifiedResultCategory, @@ -301,6 +302,12 @@ export class AssessorApiClient { recordRiskAcceptance(projectId: string, occurrenceId: string, command: RiskAcceptanceCommand, expectedRevision: number): Promise<{ review: FindingReview; repositoryRevision: number }> { return this.request(`/projects/${encodeURIComponent(projectId)}/risk-acceptances`, { method: 'POST', body: JSON.stringify({ occurrenceId, command, expectedRevision }) }); } + listScopedRiskAcceptances(projectId: string): Promise { + 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 { return this.request(unifiedQueryPath('/results', query)); diff --git a/src/app/AppProvider.tsx b/src/app/AppProvider.tsx index 4c70289..5008110 100644 --- a/src/app/AppProvider.tsx +++ b/src/app/AppProvider.tsx @@ -6,6 +6,7 @@ import { AssessorApiClient } from '../api/assessor-api-client'; import type { DatasetSheet, ImportedDataset } from '../domain/dataset'; import type { FindingReview } from '../domain/canonical-model'; import type { RiskAcceptanceCommand } from '../domain/risk-acceptance'; +import type { ScopedRiskAcceptanceWrite } from '../domain/scoped-risk-acceptance'; import { defaultReview } from '../domain/review'; import type { ImportProfile } from '../domain/import-profile'; import type { ImportBatchPreview, ImportWorkflowState } from '../domain/import-workflow'; @@ -51,6 +52,7 @@ interface AppContextValue extends AppState { closeImportWorkflow(): void; setActiveSheet(sheetId: string): void; recordRiskAcceptance(recordId: string, command: RiskAcceptanceCommand): Promise; + recordScopedRiskAcceptance(write: ScopedRiskAcceptanceWrite): Promise; setView(view: ViewMode): void; setSearch(search: string): void; setSortColumn(column: string): void; @@ -206,7 +208,7 @@ export function AppProvider({ children }: PropsWithChildren) { const activeSheet = snapshot.dataset.sheets[0]; const defaults = profile.defaults(activeSheet); setState(current => ({ - ...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews, + ...current, dataset: snapshot.dataset, activeSheetId: activeSheet?.id ?? '', reviews: snapshot.reviews, scopedRiskAcceptances: [], projectId, titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, facetFilters: {}, pathFindingIds: [], statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), @@ -297,9 +299,9 @@ export function AppProvider({ children }: PropsWithChildren) { const report = createImportReport(preview, importedIds); localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report)); const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified); - const persistedWorkspace = persistedProjectId - ? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId) - : undefined; + const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId + ? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])]) + : [undefined, []]; const workDataset = persistedWorkspace?.dataset ?? dataset; const localReviews = await reviewRepository.load(projectId); const hydrated = persistedWorkspace @@ -311,7 +313,7 @@ export function AppProvider({ children }: PropsWithChildren) { const activeProfile = profileFor(workDataset); const defaults = activeProfile.defaults(activeSheet); setState(current => ({ - ...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false, + ...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false, view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), @@ -408,9 +410,9 @@ export function AppProvider({ children }: PropsWithChildren) { const report = createImportReport(preview, importedIds); localStorage.setItem(LAST_IMPORT_REPORT_STORAGE_KEY, JSON.stringify(report)); const projectId = persistedProjectId ?? targetProjectId ?? stableProjectId(dataset.fileName, dataset.fileSize, dataset.lastModified); - const persistedWorkspace = persistedProjectId - ? await loadCanonicalProjectWorkspace(assessorApi, persistedProjectId) - : undefined; + const [persistedWorkspace, scopedRiskAcceptances] = persistedProjectId + ? await Promise.all([loadCanonicalProjectWorkspace(assessorApi, persistedProjectId), assessorApi.listScopedRiskAcceptances(persistedProjectId).catch(() => [])]) + : [undefined, []]; const workDataset = persistedWorkspace?.dataset ?? dataset; const localReviews = await reviewRepository.load(projectId); const hydrated = persistedWorkspace @@ -422,7 +424,7 @@ export function AppProvider({ children }: PropsWithChildren) { const activeProfile = profileFor(workDataset); const defaults = activeProfile.defaults(activeSheet); setState(current => ({ - ...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, projectId, search: '', actionOnly: false, + ...current, dataset: workDataset, activeSheetId: activeSheet?.id ?? '', reviews, scopedRiskAcceptances, projectId, search: '', actionOnly: false, view: 'columns', selectedRecordId: activeSheet?.analysis.records[0]?.id ?? '', titleColumn: defaults.titleColumn, sortColumn: defaults.sortColumn, sortDirection: defaults.sortDirection, statusFilter: initialSheetStatus(activeSheet, defaults.statusFilter), @@ -463,19 +465,22 @@ export function AppProvider({ children }: PropsWithChildren) { })); try { 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 (models[0]?.project.name) openProjectIdentity.current = { id: projectId, name: models[0].project.name }; if (!models.length) { persistedReviews.current = new Map(); - setState(current => ({ ...current, status: 'This client does not have a scan report yet.' })); + setState(current => ({ ...current, scopedRiskAcceptances, status: 'This client does not have a scan report yet.' })); return false; } if (!dataset) { persistedReviews.current = new Map(models.flatMap(model => model.reviews).map(review => [reviewTargetKey(review.target), review])); setActiveAssessmentIds(assessmentIds); setWorkScopeId(scopeId); - setState(current => ({ ...current, reviews: {}, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' })); + setState(current => ({ ...current, reviews: {}, scopedRiskAcceptances, selectedRecordId: '', status: 'Client opened. No displayable results are available in the selected timeslot yet.' })); return true; } activity.update('Loading work tracking and preparing the client view'); @@ -504,6 +509,7 @@ export function AppProvider({ children }: PropsWithChildren) { dataset, activeSheetId: activeSheet?.id ?? '', reviews, + scopedRiskAcceptances, projectId, search: '', actionOnly: false, @@ -644,6 +650,14 @@ export function AppProvider({ children }: PropsWithChildren) { reviewSaveQueues.current.set(key, queued); return queued; }, + recordScopedRiskAcceptance: async write => { + if (!targetProjectId || state.backendStatus !== 'connected') throw new Error('Connect to the backend and open a saved Defender scope before recording risk acceptance.'); + const saved = await assessorApi.recordScopedRiskAcceptance(targetProjectId, write); + setState(current => ({ + ...current, + scopedRiskAcceptances: [saved.acceptance, ...current.scopedRiskAcceptances.filter(item => item.id !== saved.acceptance.id)] + })); + }, snapshot: () => state.dataset ? createSnapshot(state.dataset, state.reviews) : null, setTheme, setDelimiter: delimiter => setState(current => ({ ...current, delimiter })), diff --git a/src/app/app-state.ts b/src/app/app-state.ts index 5489419..de2b4a8 100644 --- a/src/app/app-state.ts +++ b/src/app/app-state.ts @@ -1,5 +1,6 @@ import type { ImportedDataset } from '../domain/dataset'; import type { Review } from '../domain/review'; +import type { ScopedRiskAcceptance } from '../domain/scoped-risk-acceptance'; import type { DynamicFacetFilters } from '../features/search-filter/dynamic-facets'; export type ViewMode = 'records' | 'columns' | 'table' | 'raw'; @@ -20,6 +21,7 @@ export interface AppState { actionOnly: boolean; selectedRecordId: string; reviews: Record; + scopedRiskAcceptances: ScopedRiskAcceptance[]; projectId: string; status: string; theme: ThemeMode; @@ -57,6 +59,7 @@ export const initialAppState: AppState = { actionOnly: false, selectedRecordId: '', reviews: {}, + scopedRiskAcceptances: [], projectId: '', status: 'Ready', theme: 'dark', diff --git a/src/domain/scoped-risk-acceptance.ts b/src/domain/scoped-risk-acceptance.ts new file mode 100644 index 0000000..4e3f539 --- /dev/null +++ b/src/domain/scoped-risk-acceptance.ts @@ -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 { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +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(); + 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 +): 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(); + 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): ScopedRiskAcceptance { + return { ...value, capturedScopeReplaced: value.target.capturedScanRunIds.some(id => !currentScanRunIds.has(id)) }; +} + +type ScopedCoverageTarget = Pick; + +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; +} diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index 9cf8ccb..3729d7c 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -1262,7 +1262,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { : undefined; if (clientInfoView === 'ai' && showAiOverview) return ; if (hasDefender && (clientInfoView === 'defender' || pureDefender)) { - return ; + return app.recordScopedRiskAcceptance(write) : undefined} />; } const { @@ -1458,7 +1460,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { scanRunIds: app.activeScanRunIds } : undefined} /> - jump(() => setPathFindingIds([recordId]))} /> + jump(() => setPathFindingIds([recordId]))} /> }, { id: 'security', label: 'Security', content: }]} /> ); diff --git a/src/features/dashboard/RiskRegister.tsx b/src/features/dashboard/RiskRegister.tsx index 5b25619..149a83a 100644 --- a/src/features/dashboard/RiskRegister.tsx +++ b/src/features/dashboard/RiskRegister.tsx @@ -2,19 +2,22 @@ import { useState } from 'react'; import type { ImportedDataset } from '../../domain/dataset'; import type { FindingRecord } from '../../domain/finding'; import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance'; +import { defenderRiskTargetsForRecord, scopedRiskAcceptanceStateForTargets, type ScopedRiskAcceptance, type ScopedRiskAcceptanceRevision } from '../../domain/scoped-risk-acceptance'; import type { Review } from '../../domain/review'; import { TriangleIcon } from '../../components/TriangleIcon'; import { RelatedFindingRing, useRelatedViewRestore } from '../related-findings/RelatedFindings'; export interface RiskRegisterEntry { id: string; - recordId: string; + recordId?: string; occurrenceId: string; title: string; source: string; state: ReturnType; - latest: RiskAcceptanceRevision; - revisions: RiskAcceptanceRevision[]; + latest: RiskAcceptanceRevision | ScopedRiskAcceptanceRevision; + revisions: Array; + device?: string; + coveredCves?: 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. */ -export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record, today = new Date().toISOString().slice(0, 10)): RiskRegisterEntry[] { +export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record, today = new Date().toISOString().slice(0, 10), scoped: ScopedRiskAcceptance[] = []): RiskRegisterEntry[] { const entries = new Map(); dataset.sheets.forEach(sheet => sheet.analysis.records.forEach(record => { const value = reviews[record.id]?.riskAcceptance; @@ -43,6 +46,30 @@ export function riskRegisterEntries(dataset: ImportedDataset, reviews: Record 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) || left.title.localeCompare(right.title)); } @@ -77,12 +104,13 @@ function DetailGroup({ title, details, fields }: { ; } -export function RiskRegister({ dataset, reviews, onOpenFinding }: { +export function RiskRegister({ dataset, reviews, scopedRiskAcceptances = [], onOpenFinding }: { dataset: ImportedDataset; reviews: Record; + scopedRiskAcceptances?: ScopedRiskAcceptance[]; onOpenFinding?(recordId: string): void; }) { - const entries = riskRegisterEntries(dataset, reviews); + const entries = riskRegisterEntries(dataset, reviews, undefined, scopedRiskAcceptances); const [requestedId, setRequestedId] = useState(''); useRelatedViewRestore(() => () => setRequestedId(requestedId)); 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 ?

No risk acceptance has been recorded for this assessment.

:
Risk Information{selected.source}
-
{onOpenFinding && }{selected.state}
+
{onOpenFinding && selected.recordId && }{selected.state}
Finding

{selected.title}

{recordFor(selected) && onOpenFinding?.(selected.recordId)}/>}
+ {selected.coveredCves &&
Immutable system scope

{selected.device}

{selected.coveredCves.length.toLocaleString('en-US')} covered CVE{selected.coveredCves.length === 1 ? '' : 's'}: {selected.coveredCves.join(', ')}

}
diff --git a/src/features/dataset-viewer/DatasetViewer.tsx b/src/features/dataset-viewer/DatasetViewer.tsx index 3bda8dd..5c7aa21 100644 --- a/src/features/dataset-viewer/DatasetViewer.tsx +++ b/src/features/dataset-viewer/DatasetViewer.tsx @@ -361,6 +361,8 @@ export function DatasetViewer() { onNext={() => setSelectedRecord(records[Math.min(records.length - 1, records.indexOf(selected) + 1)].id)} onReview={patch => updateReview(selected.id, patch)} onRiskAcceptance={selected.findingOccurrenceId && app.backendStatus === 'connected' ? command => app.recordRiskAcceptance(selected.id, command) : undefined} + scopedRiskAcceptances={app.scopedRiskAcceptances} + onScopedRiskAcceptance={app.backendStatus === 'connected' ? write => app.recordScopedRiskAcceptance(write) : undefined} onResults={hasResults(selected) ? () => openResults(selected) : undefined} resultsOpen={Boolean(resultColor(selected.id))} resultColor={resultColor(selected.id)} diff --git a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx index 44b09ac..cd4e1f8 100644 --- a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx +++ b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx @@ -2,7 +2,16 @@ import { Fragment, useMemo, useState, type ReactNode } from 'react'; import { TriangleIcon } from '../../components/TriangleIcon'; import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot'; import type { ImportedDataset } from '../../domain/dataset'; +import type { Review } from '../../domain/review'; +import { + defenderRiskCoverageDecisions, + defenderRiskTargetsForRecord, + type DefenderRiskTarget, + type ScopedRiskAcceptance, + type ScopedRiskAcceptanceWrite +} from '../../domain/scoped-risk-acceptance'; import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl'; +import { RiskAcceptanceDialog } from '../finding-reader/RiskAcceptanceDialog'; import { defenderModelForDataset } from './defender-dataset-model'; type DefenderView = 'cves' | 'devices' | 'sites'; @@ -29,11 +38,13 @@ function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' }) return