From 2ae5bbbc8d7552e9084af9fa4991976d751e5fba Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Wed, 1 Jul 2026 17:36:16 -0400 Subject: [PATCH 01/12] new issue + notes schema --- .../migration.sql | 151 +++++ prisma/schema.prisma | 606 ++++++++++-------- prisma/seed.ts | 2 +- scripts/seed-icsma-24-319-01.ts | 4 +- .../api/v1/__tests__/vulnerabilities.test.ts | 9 +- src/components/status-form.tsx | 17 +- .../advisories/components/advisories.tsx | 4 +- src/features/advisories/server/routers.ts | 2 +- src/features/advisories/types.ts | 4 +- src/features/assets/components/asset.tsx | 28 +- .../assets/components/dashboard-columns.tsx | 7 +- src/features/assets/params.ts | 6 +- src/features/assets/server/routers.ts | 12 +- src/features/chat/utils.ts | 16 +- .../tools/get-recommendations-context.ts | 10 +- src/features/issues/components/issue.tsx | 32 +- src/features/issues/hooks/use-issues.ts | 12 +- src/features/tracking/types.ts | 2 +- .../components/prioritized-columns.tsx | 15 +- src/lib/prisma-client-extensions.ts | 56 +- 20 files changed, 661 insertions(+), 334 deletions(-) create mode 100644 prisma/migrations/20260701212209_issue_and_notes/migration.sql diff --git a/prisma/migrations/20260701212209_issue_and_notes/migration.sql b/prisma/migrations/20260701212209_issue_and_notes/migration.sql new file mode 100644 index 00000000..5d247430 --- /dev/null +++ b/prisma/migrations/20260701212209_issue_and_notes/migration.sql @@ -0,0 +1,151 @@ +/* + Warnings: + + - The `status` column on the `advisory` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `status` column on the `issue` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - A unique constraint covering the columns `[deviceGroupMatchingId,vulnerabilityId]` on the table `issue` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "IssueStatus_new" AS ENUM ('NOT_AFFECTED', 'AFFECTED', 'FIXED', 'UNDER_INVESTIGATION'); + +-- CreateEnum +CREATE TYPE "NotAffectedJustification" AS ENUM ('COMPONENT_NOT_PRESENT', 'VULNERABLE_CODE_NOT_PRESENT', 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH', 'VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY', 'INLINE_MITIGATIONS_ALREADY_EXIST', 'HOSPITAL_COMPENSATING_CONTROL', 'HOSPITAL_ACCEPTS_RISK'); + +-- CreateEnum +CREATE TYPE "NoteStatus" AS ENUM ('PERSISTENT', 'SCOPED'); + +-- CreateEnum +CREATE TYPE "ScopeTargetModel" AS ENUM ('DEVICE_GROUP', 'DEVICE_GROUP_MATCHING', 'ASSET', 'VULNERABILITY', 'REMEDIATION'); + +-- AlterTable +-- Remap "advisory"."status" to the new IssueStatus values without dropping +-- the column, so existing data is preserved instead of discarded. +ALTER TABLE "advisory" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "advisory" ALTER COLUMN "status" TYPE "IssueStatus_new" + USING ( + CASE "status"::text + WHEN 'ACTIVE' THEN 'AFFECTED' + WHEN 'FALSE_POSITIVE' THEN 'NOT_AFFECTED' + WHEN 'REMEDIATED' THEN 'FIXED' + END + )::"IssueStatus_new"; +ALTER TABLE "advisory" ALTER COLUMN "status" SET DEFAULT 'AFFECTED'; + +-- AlterTable +ALTER TABLE "issue" ADD COLUMN "deviceGroupMatchingId" TEXT, +ADD COLUMN "notAffectedJustification" "NotAffectedJustification", +ADD COLUMN "statusConfidence" "ConfidenceLevel", +ADD COLUMN "statusNotes" TEXT, +ALTER COLUMN "assetId" DROP NOT NULL; + +-- Remap "issue"."status" to the new IssueStatus values without dropping +-- the column, so existing per-asset issues keep their status +-- (ACTIVE->AFFECTED, FALSE_POSITIVE->NOT_AFFECTED, REMEDIATED->FIXED). +ALTER TABLE "issue" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "issue" ALTER COLUMN "status" TYPE "IssueStatus_new" + USING ( + CASE "status"::text + WHEN 'ACTIVE' THEN 'AFFECTED' + WHEN 'FALSE_POSITIVE' THEN 'NOT_AFFECTED' + WHEN 'REMEDIATED' THEN 'FIXED' + END + )::"IssueStatus_new"; +ALTER TABLE "issue" ALTER COLUMN "status" SET DEFAULT 'AFFECTED'; + +-- DropEnum (old IssueStatus, now unreferenced since both columns were +-- repointed to IssueStatus_new above) +DROP TYPE "IssueStatus"; + +-- Rename the new enum into the name the old one vacated +ALTER TYPE "IssueStatus_new" RENAME TO "IssueStatus"; + +-- CreateTable +CREATE TABLE "note" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "status" "NoteStatus" NOT NULL DEFAULT 'SCOPED', + "userId" TEXT, + "targetModel" "ScopeTargetModel", + "instanceId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "note_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "entity_filter" ( + "id" TEXT NOT NULL, + "label" TEXT, + "targetModel" "ScopeTargetModel" NOT NULL, + "filter" JSONB NOT NULL, + "noteId" TEXT, + "issueId" TEXT, + "lastResolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "entity_filter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "entity_filter_match" ( + "id" TEXT NOT NULL, + "entityFilterId" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "entity_filter_match_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "note_targetModel_instanceId_idx" ON "note"("targetModel", "instanceId"); + +-- CreateIndex +CREATE INDEX "note_userId_idx" ON "note"("userId"); + +-- CreateIndex +CREATE INDEX "note_status_idx" ON "note"("status"); + +-- CreateIndex +CREATE INDEX "entity_filter_noteId_idx" ON "entity_filter"("noteId"); + +-- CreateIndex +CREATE INDEX "entity_filter_issueId_idx" ON "entity_filter"("issueId"); + +-- CreateIndex +CREATE INDEX "entity_filter_targetModel_idx" ON "entity_filter"("targetModel"); + +-- CreateIndex +CREATE INDEX "entity_filter_match_targetId_idx" ON "entity_filter_match"("targetId"); + +-- CreateIndex +CREATE UNIQUE INDEX "entity_filter_match_entityFilterId_targetId_key" ON "entity_filter_match"("entityFilterId", "targetId"); + +-- CreateIndex +CREATE INDEX "issue_assetId_idx" ON "issue"("assetId"); + +-- CreateIndex +CREATE INDEX "issue_deviceGroupMatchingId_idx" ON "issue"("deviceGroupMatchingId"); + +-- CreateIndex +CREATE INDEX "issue_vulnerabilityId_idx" ON "issue"("vulnerabilityId"); + +-- CreateIndex +CREATE UNIQUE INDEX "issue_deviceGroupMatchingId_vulnerabilityId_key" ON "issue"("deviceGroupMatchingId", "vulnerabilityId"); + +-- AddForeignKey +ALTER TABLE "issue" ADD CONSTRAINT "issue_deviceGroupMatchingId_fkey" FOREIGN KEY ("deviceGroupMatchingId") REFERENCES "device_group_matching"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "note" ADD CONSTRAINT "note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "entity_filter" ADD CONSTRAINT "entity_filter_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "entity_filter" ADD CONSTRAINT "entity_filter_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "entity_filter_match" ADD CONSTRAINT "entity_filter_match_entityFilterId_fkey" FOREIGN KEY ("entityFilterId") REFERENCES "entity_filter"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b4cb228..079223ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,47 +10,48 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } model User { - id String @id - name String - email String? - emailVerified Boolean @default(false) - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - sessions Session[] - accounts Account[] - workflows Workflow[] - assets Asset[] - vulnerabilities Vulnerability[] - remediations Remediation[] - deviceArtifacts DeviceArtifact[] - artifacts Artifact[] - artifactWrappers ArtifactWrapper[] - advisories Advisory[] - - apikeys Apikey[] - apiKeyConnectors ApiKeyConnector[] - integration Integration[] - integrationUser Integration? @relation("integration_user") - webhooks Webhook[] - userTokens UserToken[] - threads ChatThread[] - memories Memory[] - notificationReads NotificationRead[] - - departmentId String? - department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) - workOrderTicketsCreated WorkOrderTicket[] @relation("WorkOrderTicketCreator") - workOrderTicketsAssigned WorkOrderTicket[] @relation("WorkOrderTicketAssignee") - ticketComments TicketComment[] - watchedTickets TicketWatch[] - ticketSeen TicketSeen[] - ticketActivities TicketActivity[] + id String @id + name String + email String? + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + sessions Session[] + accounts Account[] + workflows Workflow[] + assets Asset[] + vulnerabilities Vulnerability[] + remediations Remediation[] + deviceArtifacts DeviceArtifact[] + artifacts Artifact[] + artifactWrappers ArtifactWrapper[] + advisories Advisory[] + + apikeys Apikey[] + apiKeyConnectors ApiKeyConnector[] + integration Integration[] + integrationUser Integration? @relation("integration_user") + webhooks Webhook[] + userTokens UserToken[] + threads ChatThread[] + memories Memory[] + notificationReads NotificationRead[] + + departmentId String? + department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + workOrderTicketsCreated WorkOrderTicket[] @relation("WorkOrderTicketCreator") + workOrderTicketsAssigned WorkOrderTicket[] @relation("WorkOrderTicketAssignee") + ticketComments TicketComment[] + watchedTickets TicketWatch[] + ticketSeen TicketSeen[] + ticketActivities TicketActivity[] + notes Note[] @@unique([email]) @@index([departmentId]) @@ -106,13 +107,13 @@ model Verification { } model UserToken { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - tokenHash String @unique + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique permissions String? // what can the token be used for? - expiresAt DateTime - createdAt DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) @@index([userId]) @@index([expiresAt]) @@ -219,9 +220,9 @@ enum VersScheme { // Canonical vendor registry. `nameMappings` are aliases that resolve to this row. model Vendor { id String @id @default(cuid()) - canonicalName String @unique - canonicalDisplayName String - hasCpe Boolean @default(false) + canonicalName String @unique + canonicalDisplayName String + hasCpe Boolean @default(false) nameMappings String[] @default([]) deviceGroups DeviceGroup[] matchings DeviceGroupMatching[] @@ -276,8 +277,8 @@ model DeviceGroup { udi String? helmSbomId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deviceGroupHistories DeviceGroupHistory[] assets Asset[] // Vulnerabilities / remediations / advisories / device artifacts connect via @@ -293,7 +294,6 @@ model DeviceGroup { @@map("device_group") } - model DeviceGroupMatching { id String @id @default(cuid()) @@ -310,6 +310,7 @@ model DeviceGroupMatching { remediations Remediation[] @relation("RemediationMatchings") advisories Advisory[] @relation("AdvisoryMatchings") deviceArtifacts DeviceArtifact[] @relation("DeviceArtifactMatchings") + issues Issue[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -361,7 +362,7 @@ model Asset { issues Issue[] deviceGroupHistories DeviceGroupHistory[] externalMappings ExternalAssetMapping[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAssets") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAssets") notificationMappings NotificationAssetMapping[] createdAt DateTime @default(now()) @@ -376,7 +377,7 @@ model ExternalAssetMapping { id String @id @default(cuid()) itemId String integrationId String - externalId String // the id the asset has on say, Blueflow + externalId String // the id the asset has on say, Blueflow // Relationships item Asset @relation(fields: [itemId], references: [id], onDelete: Cascade) integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) @@ -411,14 +412,14 @@ model ExternalDeviceArtifactMapping { } model ExternalRemediationMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) itemId String integrationId String externalId String - item Remediation @relation(fields: [itemId], references: [id], onDelete: Cascade) - integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + item Remediation @relation(fields: [itemId], references: [id], onDelete: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt lastSynced DateTime? @@unique([itemId, integrationId], name: "external_remediation_mappings_item_integration_key") @@ -445,23 +446,24 @@ model ExternalVulnerabilityMapping { } model ArtifactWrapper { - id String @id @default(cuid()) + id String @id @default(cuid()) deviceArtifactId String? - deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: Cascade) - remediationId String? - remediation Remediation? @relation(fields: [remediationId], references: [id], onDelete: Cascade) + deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: Cascade) + remediationId String? + remediation Remediation? @relation(fields: [remediationId], references: [id], onDelete: Cascade) - latestArtifactId String? @unique - latestArtifact Artifact? @relation("LatestArtifact", fields: [latestArtifactId], references: [id], onDelete: SetNull) + latestArtifactId String? @unique + latestArtifact Artifact? @relation("LatestArtifact", fields: [latestArtifactId], references: [id], onDelete: SetNull) artifacts Artifact[] @relation("ArtifactWrapper") // back of fk relation userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([remediationId]) @@index([deviceArtifactId]) @@index([userId]) @@ -470,19 +472,19 @@ model ArtifactWrapper { } model Artifact { - id String @id @default(cuid()) + id String @id @default(cuid()) wrapperId String - wrapper ArtifactWrapper @relation("ArtifactWrapper", fields: [wrapperId], references: [id], onDelete: Cascade) + wrapper ArtifactWrapper @relation("ArtifactWrapper", fields: [wrapperId], references: [id], onDelete: Cascade) - name String? + name String? artifactType ArtifactType - downloadUrl String? @map("download-url") // Either provided by the performer or an artifact in S3 uploaded to us - hash String? // Verify incomplete or invalid uploads - size Int? // size in bytes + downloadUrl String? @map("download-url") // Either provided by the performer or an artifact in S3 uploaded to us + hash String? // Verify incomplete or invalid uploads + size Int? // size in bytes - prevVersionId String? @unique // null for v1 - prevVersion Artifact? @relation("VersionChain", fields: [prevVersionId], references: [id], onDelete: SetNull) - nextVersion Artifact? @relation("VersionChain") // opposite side of prevVersion + prevVersionId String? @unique // null for v1 + prevVersion Artifact? @relation("VersionChain", fields: [prevVersionId], references: [id], onDelete: SetNull) + nextVersion Artifact? @relation("VersionChain") // opposite side of prevVersion versionNumber Int // 1, 2, 3... @@ -491,11 +493,12 @@ model Artifact { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([wrapperId, versionNumber]) @@index([wrapperId]) @@index([userId]) - @@unique([wrapperId, versionNumber]) @@map("artifact") } @@ -553,49 +556,49 @@ enum AlohaStatus { } model Vulnerability { - id String @id @default(cuid()) + id String @id @default(cuid()) cveId String? - description String? @db.Text // description of vulnerability - narrative String? @db.Text // how a vulnerability could be exploited - impact String? @db.Text // clinical impact - severity Severity @default(Medium) + description String? @db.Text // description of vulnerability + narrative String? @db.Text // how a vulnerability could be exploited + impact String? @db.Text // clinical impact + severity Severity @default(Medium) cvssScore Float? cvssVector String? sarif Json - affectedComponents String[] @default([]) + affectedComponents String[] @default([]) deviceGroupMatchings DeviceGroupMatching[] @relation("VulnerabilityMatchings") - advisories Advisory[] @relation("AdvisoryVulnerabilities") - exploitUri String? @map("exploit-uri") - upstreamApi String? @map("upstream-api") + advisories Advisory[] @relation("AdvisoryVulnerabilities") + exploitUri String? @map("exploit-uri") + upstreamApi String? @map("upstream-api") // EPSS (Exploit Prediction Scoring System) fields - epss Float? // Probability score between 0.0 and 1.0 - updatedEpss DateTime? // When EPSS score was last updated - + epss Float? // Probability score between 0.0 and 1.0 + updatedEpss DateTime? // When EPSS score was last updated + // KEV (Known Exploited Vulnerabilities) fields - inKEV Boolean @default(false) // Whether in CISA KEV catalog - updatedInKev DateTime? // When KEV status was last checked/updated + inKEV Boolean @default(false) // Whether in CISA KEV catalog + updatedInKev DateTime? // When KEV status was last checked/updated - priority Priority @default(Unsorted) + priority Priority @default(Unsorted) alohaStatus AlohaStatus? - alohaLog Json @default("{}") + alohaLog Json @default("{}") - deviceArtifactId String? - deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: SetNull) + deviceArtifactId String? + deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: SetNull) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) remediations Remediation[] issues Issue[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketVulns") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketVulns") notificationMappings NotificationVulnerabilityMapping[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - externalMappings ExternalVulnerabilityMapping[] + externalMappings ExternalVulnerabilityMapping[] @@index([userId]) @@index([deviceArtifactId]) @@ -607,53 +610,148 @@ model Vulnerability { } enum IssueStatus { - ACTIVE - FALSE_POSITIVE - REMEDIATED + NOT_AFFECTED + AFFECTED + FIXED + UNDER_INVESTIGATION +} + +enum NotAffectedJustification { + COMPONENT_NOT_PRESENT + VULNERABLE_CODE_NOT_PRESENT + VULNERABLE_CODE_NOT_IN_EXECUTE_PATH + VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY + INLINE_MITIGATIONS_ALREADY_EXIST + HOSPITAL_COMPENSATING_CONTROL + HOSPITAL_ACCEPTS_RISK } model Issue { id String @id @default(cuid()) - assetId String - asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + deviceGroupMatchingId String? + deviceGroupMatching DeviceGroupMatching? @relation(fields: [deviceGroupMatchingId], references: [id], onDelete: Cascade) + + assetId String? // now optional; when set, overrides the deviceGroupMatching-level Issue for this vuln + asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) vulnerabilityId String vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade) - issueRemediations IssueRemediation[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues") + status IssueStatus @default(AFFECTED) + notAffectedJustification NotAffectedJustification? + statusConfidence ConfidenceLevel? + statusNotes String? @db.Text - status IssueStatus @default(ACTIVE) + issueRemediations IssueRemediation[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues") + filters EntityFilter[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([assetId, vulnerabilityId]) + @@unique([deviceGroupMatchingId, vulnerabilityId]) + @@index([assetId]) + @@index([deviceGroupMatchingId]) + @@index([vulnerabilityId]) @@map("issue") } +enum NoteStatus { + PERSISTENT // always included in relevant LLM prompts, regardless of scope + SCOPED +} + +enum ScopeTargetModel { + DEVICE_GROUP + DEVICE_GROUP_MATCHING + ASSET + VULNERABILITY + REMEDIATION +} + +model Note { + id String @id @default(cuid()) + text String @db.Text + status NoteStatus @default(SCOPED) + + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + filters EntityFilter[] + + targetModel ScopeTargetModel? + instanceId String? // weak reference — app validates this id exists in targetModel's table + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([targetModel, instanceId]) + @@index([userId]) + @@index([status]) + @@map("note") +} + +model EntityFilter { + id String @id @default(cuid()) + label String? // e.g. "All infusion pumps in the ICU" + targetModel ScopeTargetModel + filter Json // WHERE-clause fragment evaluated against targetModel's table + + noteId String? + note Note? @relation(fields: [noteId], references: [id], onDelete: Cascade) + issueId String? + issue Issue? @relation(fields: [issueId], references: [id], onDelete: Cascade) + // Exactly one of noteId/issueId must be set — enforce at the app layer (tRPC input + // validation); a raw-SQL check constraint could be added later via a manual migration. + + matches EntityFilterMatch[] + lastResolvedAt DateTime? // null until a future memoization job runs + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([noteId]) + @@index([issueId]) + @@index([targetModel]) + @@map("entity_filter") +} + +model EntityFilterMatch { + id String @id @default(cuid()) + entityFilterId String + entityFilter EntityFilter @relation(fields: [entityFilterId], references: [id], onDelete: Cascade) + targetId String // weak reference into whatever table entityFilter.targetModel points to + + createdAt DateTime @default(now()) + + @@unique([entityFilterId, targetId]) + @@index([targetId]) + @@map("entity_filter_match") +} + model Remediation { - id String @id @default(cuid()) + id String @id @default(cuid()) artifacts ArtifactWrapper[] deviceGroupMatchings DeviceGroupMatching[] @relation("RemediationMatchings") - description String? @db.Text // description of remediation - narrative String? @db.Text // how a remediation can be applied - upstreamApi String? @map("upstream-api") + description String? @db.Text // description of remediation + narrative String? @db.Text // how a remediation can be applied + upstreamApi String? @map("upstream-api") vulnerabilityId String? vulnerability Vulnerability? @relation(fields: [vulnerabilityId], references: [id], onDelete: SetNull) issueRemediations IssueRemediation[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketRemediations") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketRemediations") notificationMappings NotificationRemediationMapping[] userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) alohaStatus AlohaStatus? - alohaLog Json @default("{}") + alohaLog Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -666,11 +764,11 @@ model Remediation { } model IssueRemediation { - issueId String - remediationId String + issueId String + remediationId String - issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) - remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) @@id([issueId, remediationId]) @@map("issue_remediation") @@ -700,37 +798,37 @@ model Apikey { permissions String? metadata String? - connector ApiKeyConnector? + connector ApiKeyConnector? @@map("apikey") } model Integration { - id String @id @default(cuid()) - name String - platform String? // The name of the integration platform, e.g "BlueFlow" - integrationUri String @map("integration-uri") - integrationType IntegrationType + id String @id @default(cuid()) + name String + platform String? // The name of the integration platform, e.g "BlueFlow" + integrationUri String @map("integration-uri") + integrationType IntegrationType // ^PARTNER: Helm, Blueflow. Follows VIPER standard // AI: uses AI to pull data from any platform // CSAF: parses CSAF security advisories (Vulnerability resource type only) - prompt String? @db.Text // optional, additional instructions for AI - authType AuthType - resourceType ResourceType - authentication Json? // TODO: this needs to be encrypted - syncEvery Int // e.g 300, sync every 300 seconds - syncStatus SyncStatus[] + prompt String? @db.Text // optional, additional instructions for AI + authType AuthType + resourceType ResourceType + authentication Json? // TODO: this needs to be encrypted + syncEvery Int // e.g 300, sync every 300 seconds + syncStatus SyncStatus[] lastSuccessfulSync DateTime? - apiKeyConnector ApiKeyConnector? + apiKeyConnector ApiKeyConnector? // The user who created the integration - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) // the user that this integration creates - integrationUserId String @unique - integrationUser User @relation(name: "integration_user", fields: [integrationUserId], references: [id], onDelete: Cascade) + integrationUserId String @unique + integrationUser User @relation(name: "integration_user", fields: [integrationUserId], references: [id], onDelete: Cascade) assetMappings ExternalAssetMapping[] deviceArtifactMappings ExternalDeviceArtifactMapping[] @@ -738,8 +836,8 @@ model Integration { vulnerabilityMappings ExternalVulnerabilityMapping[] advisoryMappings ExternalAdvisoryMapping[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@index([integrationUserId]) @@ -748,21 +846,21 @@ model Integration { // separate out to preserve between ApiKey rotations model ApiKeyConnector { - id String @id @default(cuid()) + id String @id @default(cuid()) - name String? - resourceType ResourceType? - lastRequest DateTime? + name String? + resourceType ResourceType? + lastRequest DateTime? - apiKeyId String? @unique - apiKey Apikey? @relation(fields: [apiKeyId], references: [id], onDelete: SetNull) - integrationId String? @unique - integration Integration? @relation(fields: [integrationId], references: [id], onDelete: SetNull) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + apiKeyId String? @unique + apiKey Apikey? @relation(fields: [apiKeyId], references: [id], onDelete: SetNull) + integrationId String? @unique + integration Integration? @relation(fields: [integrationId], references: [id], onDelete: SetNull) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@map("api_key_connector") @@ -780,15 +878,15 @@ model SyncStatus { integrationId String status SyncStatusEnum @default(Pending) errorMessage String? - syncedAt DateTime @default(now()) + syncedAt DateTime @default(now()) @@map("sync_status") } model Webhook { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) triggers TriggerEnum[] callbackUrl String @@ -796,8 +894,8 @@ model Webhook { authType AuthType authentication Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) } @@ -850,23 +948,23 @@ enum Tlp { } model Advisory { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String title String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - referencedVulnerabilities Vulnerability[] @relation("AdvisoryVulnerabilities") - deviceGroupMatchings DeviceGroupMatching[] @relation("AdvisoryMatchings") - csaf Json @default("{}") - upstreamUrl String? @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + referencedVulnerabilities Vulnerability[] @relation("AdvisoryVulnerabilities") + deviceGroupMatchings DeviceGroupMatching[] @relation("AdvisoryMatchings") + csaf Json @default("{}") + upstreamUrl String? @unique severity Severity - summary String? @db.Text - status IssueStatus @default(ACTIVE) + summary String? @db.Text + status IssueStatus @default(AFFECTED) tlp Tlp? publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt externalMappings ExternalAdvisoryMapping[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAdvisories") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAdvisories") @@index([userId]) @@map("advisory") @@ -890,13 +988,13 @@ model ExternalAdvisoryMapping { } model ChatThread { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - title String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - messages ChatMessage[] + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages ChatMessage[] @@index([userId]) } @@ -913,17 +1011,17 @@ enum ChatMessageRole { } model ChatMessage { - id String @id @default(cuid()) - threadId String - thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) - status ChatMessageStatus? - role ChatMessageRole - content String? @db.Text - toolCalls Json? + id String @id @default(cuid()) + threadId String + thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + status ChatMessageStatus? + role ChatMessageRole + content String? @db.Text + toolCalls Json? toolStatus String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - checksum String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checksum String? @@index([threadId, createdAt]) } @@ -959,12 +1057,12 @@ enum TicketCategory { } model Department { - id String @id @default(cuid()) - name String @unique - description String? @db.Text + id String @id @default(cuid()) + name String @unique + description String? @db.Text color String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt users User[] tickets WorkOrderTicket[] @relation("WorkOrderTicketDepartments") @@ -984,14 +1082,14 @@ model CategoryColor { } model WorkOrderTicket { - id String @id @default(cuid()) - summary String + id String @id @default(cuid()) + summary String - status TicketStatus @default(TO_DO) - category TicketCategory @default(OTHER) + status TicketStatus @default(TO_DO) + category TicketCategory @default(OTHER) sourceLabel String? - sources NotificationSource[] + sources NotificationSource[] body String? @db.Text @@ -999,33 +1097,33 @@ model WorkOrderTicket { departments Department[] @relation("WorkOrderTicketDepartments") - parentId String? - parent WorkOrderTicket? @relation("WorkOrderTicketChildren", fields: [parentId], references: [id], onDelete: Cascade) - children WorkOrderTicket[] @relation("WorkOrderTicketChildren") + parentId String? + parent WorkOrderTicket? @relation("WorkOrderTicketChildren", fields: [parentId], references: [id], onDelete: Cascade) + children WorkOrderTicket[] @relation("WorkOrderTicketChildren") - creatorId String - creator User @relation("WorkOrderTicketCreator", fields: [creatorId], references: [id], onDelete: Cascade) - assigneeId String? - assignee User? @relation("WorkOrderTicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + creatorId String + creator User @relation("WorkOrderTicketCreator", fields: [creatorId], references: [id], onDelete: Cascade) + assigneeId String? + assignee User? @relation("WorkOrderTicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) scheduledAt DateTime? - issues Issue[] @relation("WorkOrderTicketIssues") - vulnerabilities Vulnerability[] @relation("WorkOrderTicketVulns") - remediations Remediation[] @relation("WorkOrderTicketRemediations") - advisories Advisory[] @relation("WorkOrderTicketAdvisories") - assets Asset[] @relation("WorkOrderTicketAssets") + issues Issue[] @relation("WorkOrderTicketIssues") + vulnerabilities Vulnerability[] @relation("WorkOrderTicketVulns") + remediations Remediation[] @relation("WorkOrderTicketRemediations") + advisories Advisory[] @relation("WorkOrderTicketAdvisories") + assets Asset[] @relation("WorkOrderTicketAssets") deviceGroups NotificationDeviceGroupMapping[] - comments TicketComment[] - descriptions TicketDescription[] - watchers TicketWatch[] - seenBy TicketSeen[] - activities TicketActivity[] - lastCommentAt DateTime? + comments TicketComment[] + descriptions TicketDescription[] + watchers TicketWatch[] + seenBy TicketSeen[] + activities TicketActivity[] + lastCommentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status]) @@index([assigneeId]) @@ -1077,14 +1175,14 @@ enum TicketActivityType { } model TicketActivity { - id String @id @default(cuid()) + id String @id @default(cuid()) ticketId String - ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) type TicketActivityType - data Json // shape varies by `type`; structured server-side - createdAt DateTime @default(now()) + data Json // shape varies by `type`; structured server-side + createdAt DateTime @default(now()) @@index([ticketId, createdAt]) @@index([userId]) @@ -1092,14 +1190,14 @@ model TicketActivity { } model TicketComment { - id String @id @default(cuid()) + id String @id @default(cuid()) ticketId String ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) authorId String - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - body String @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + body String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([ticketId, createdAt]) @@index([authorId]) @@ -1157,9 +1255,9 @@ model Notification { summary String? @db.Text details Json? // unindexed, NotificationType-specific fields - priority Priority @default(Unsorted) - priorityReasonWhy String? @db.Text - hospitalImpact String? @db.Text + priority Priority @default(Unsorted) + priorityReasonWhy String? @db.Text + hospitalImpact String? @db.Text sources NotificationSource[] reads NotificationRead[] @@ -1176,24 +1274,24 @@ model Notification { } model NotificationSource { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String? - notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) workOrderTicketId String? workOrderTicket WorkOrderTicket? @relation(fields: [workOrderTicketId], references: [id], onDelete: Cascade) sourceType NotificationSourceType @default(Source) - reasonWhy String? @db.Text // why was this email linked to this notification? + reasonWhy String? @db.Text // why was this email linked to this notification? channel NotificationChannel externalId String? // for an email, this is the resend email id referenceUrl String? raw Json - markdown String? @db.Text + markdown String? @db.Text attachments NotificationAttachment[] - receivedAt DateTime @default(now()) + receivedAt DateTime @default(now()) @@unique([channel, externalId]) @@index([notificationId]) @@ -1202,9 +1300,9 @@ model NotificationSource { } model NotificationAttachment { - id String @id @default(cuid()) - sourceId String - source NotificationSource @relation(fields: [sourceId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + sourceId String + source NotificationSource @relation(fields: [sourceId], references: [id], onDelete: Cascade) filename String? contentType String? @@ -1232,29 +1330,29 @@ model NotificationRead { } model NotificationAssetMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) assetId String - asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? - reasonWhy String? + reasonWhy String? @@unique([notificationId, assetId]) @@map("notification_asset_mapping") } model NotificationDeviceGroupMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) // A mapping belongs to a Notification OR a WorkOrderTicket (exactly one). - notificationId String? - notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notificationId String? + notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) workOrderTicketId String? workOrderTicket WorkOrderTicket? @relation(fields: [workOrderTicketId], references: [id], onDelete: Cascade) - deviceGroupId String - deviceGroup DeviceGroup @relation(fields: [deviceGroupId], references: [id], onDelete: Cascade) - confidence ConfidenceLevel? - reasonWhy String? + deviceGroupId String + deviceGroup DeviceGroup @relation(fields: [deviceGroupId], references: [id], onDelete: Cascade) + confidence ConfidenceLevel? + reasonWhy String? @@unique([notificationId, deviceGroupId]) @@unique([workOrderTicketId, deviceGroupId], map: "ndg_mapping_workorder_devicegroup_key") @@ -1263,26 +1361,26 @@ model NotificationDeviceGroupMapping { } model NotificationVulnerabilityMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) vulnerabilityId String - vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade) + vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? - reasonWhy String? + reasonWhy String? @@unique([notificationId, vulnerabilityId]) @@map("notification_vulnerability_mapping") } model NotificationRemediationMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) remediationId String - remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) + remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? - reasonWhy String? + reasonWhy String? @@unique([notificationId, remediationId]) @@map("notification_remediation_mapping") diff --git a/prisma/seed.ts b/prisma/seed.ts index 26477765..5b720723 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1577,7 +1577,7 @@ async function seedIssues() { data: { assetId: asset.id, vulnerabilityId: vulnerability.id, - status: "ACTIVE", + status: "AFFECTED", }, }); issues.push(issue); diff --git a/scripts/seed-icsma-24-319-01.ts b/scripts/seed-icsma-24-319-01.ts index b897758e..99c3441d 100644 --- a/scripts/seed-icsma-24-319-01.ts +++ b/scripts/seed-icsma-24-319-01.ts @@ -747,7 +747,7 @@ async function seedAdvisory( summary: "Successful exploitation of these vulnerabilities could lead to information disclosure and/or disruption of the device's function without detection.", publishedAt: new Date("2024-11-14T07:00:00.000Z"), - status: IssueStatus.ACTIVE, + status: IssueStatus.AFFECTED, csaf: CSAF_JSON, referencedVulnerabilities: { set: vulnIds.map((id) => ({ id })), @@ -765,7 +765,7 @@ async function seedAdvisory( summary: "Successful exploitation of these vulnerabilities could lead to information disclosure and/or disruption of the device's function without detection.", publishedAt: new Date("2024-11-14T07:00:00.000Z"), - status: IssueStatus.ACTIVE, + status: IssueStatus.AFFECTED, csaf: CSAF_JSON, referencedVulnerabilities: { connect: vulnIds.map((id) => ({ id })), diff --git a/src/app/api/v1/__tests__/vulnerabilities.test.ts b/src/app/api/v1/__tests__/vulnerabilities.test.ts index cf5f9d07..2265ec10 100644 --- a/src/app/api/v1/__tests__/vulnerabilities.test.ts +++ b/src/app/api/v1/__tests__/vulnerabilities.test.ts @@ -141,7 +141,7 @@ describe("Vulnerabilities Endpoint (/vulnerabilities)", () => { }); expect(foundIssue.length).toBe(1); - expect(foundIssue[0].assetId).toBe(postAssetRes.body.id); + expect(foundIssue[0].assetId).toBeNull(); expect(foundIssue[0].vulnerabilityId).toBe(res.body.id); expect(Array.isArray(detailRes.body.deviceGroupMatchings)).toBe(true); @@ -149,6 +149,13 @@ describe("Vulnerabilities Endpoint (/vulnerabilities)", () => { // Check that the array has one element expect(detailRes.body.deviceGroupMatchings.length).toBe(1); + // The single issue should be scoped to that one device group matching, + // not to the individual asset (one issue per affected device group, not + // one per asset). + expect(foundIssue[0].deviceGroupMatchingId).toBe( + detailRes.body.deviceGroupMatchings[0].id, + ); + // Check that the single object in the array has the correct match values // (resolved from the uploaded CPE: cpe:2.3::::) const [, , , cpeVendor, cpeProduct, cpeVersion] = diff --git a/src/components/status-form.tsx b/src/components/status-form.tsx index 81f4cb17..e9f1adb5 100644 --- a/src/components/status-form.tsx +++ b/src/components/status-form.tsx @@ -12,12 +12,16 @@ import { import { IssueStatus } from "@/generated/prisma"; export const statusDetails = { - [IssueStatus.FALSE_POSITIVE]: { - name: "False Positive", + [IssueStatus.NOT_AFFECTED]: { + name: "Not Affected", + color: "bg-gray-500", + }, + [IssueStatus.AFFECTED]: { name: "Active", color: "bg-red-500" }, + [IssueStatus.FIXED]: { name: "Remediated", color: "bg-green-500" }, + [IssueStatus.UNDER_INVESTIGATION]: { + name: "Under Investigation", color: "bg-yellow-500", }, - [IssueStatus.ACTIVE]: { name: "Active", color: "bg-red-500" }, - [IssueStatus.REMEDIATED]: { name: "Remediated", color: "bg-green-500" }, }; export const IssueStatusBadge = ({ status }: { status: IssueStatus }) => { @@ -33,7 +37,10 @@ export const StatusFormBase = ({ }: { id: string; initialStatus: IssueStatus; - onUpdate: (input: { id: string; status: IssueStatus }) => Promise; + onUpdate: (input: { + id: string; + status: IssueStatus; + }) => Promise; className?: string; }) => { const [status, setStatus] = useState(initialStatus); diff --git a/src/features/advisories/components/advisories.tsx b/src/features/advisories/components/advisories.tsx index 9fb84ba8..24bcd2d9 100644 --- a/src/features/advisories/components/advisories.tsx +++ b/src/features/advisories/components/advisories.tsx @@ -193,10 +193,10 @@ function AdvisoryIssueProgressBar({ if (total === 0) return null; const remediated = issues.filter( - (i) => i.status === IssueStatus.REMEDIATED, + (i) => i.status === IssueStatus.FIXED, ).length; const falsePos = issues.filter( - (i) => i.status === IssueStatus.FALSE_POSITIVE, + (i) => i.status === IssueStatus.NOT_AFFECTED, ).length; const active = total - remediated - falsePos; diff --git a/src/features/advisories/server/routers.ts b/src/features/advisories/server/routers.ts index cfee9334..9b2f3274 100644 --- a/src/features/advisories/server/routers.ts +++ b/src/features/advisories/server/routers.ts @@ -76,7 +76,7 @@ export const advisoriesRouter = createTRPCRouter({ const allIssues = affectedAssetsWithIssues.flatMap((a) => a.issues); const nonActiveCount = allIssues.filter( - (i) => i.status !== IssueStatus.ACTIVE, + (i) => i.status !== IssueStatus.AFFECTED, ).length; const progressPercent = allIssues.length > 0 diff --git a/src/features/advisories/types.ts b/src/features/advisories/types.ts index d7f8125f..88000112 100644 --- a/src/features/advisories/types.ts +++ b/src/features/advisories/types.ts @@ -32,7 +32,9 @@ export function getAffectedAssets(advisory: AdvisoryWithRelations): Asset[] { const assetMap = new Map(); for (const vuln of advisory.referencedVulnerabilities) { for (const issue of vuln.issues) { - assetMap.set(issue.asset.id, issue.asset); + if (issue.asset) { + assetMap.set(issue.asset.id, issue.asset); + } } } return [...assetMap.values()]; diff --git a/src/features/assets/components/asset.tsx b/src/features/assets/components/asset.tsx index 4e0cda5f..73a19331 100644 --- a/src/features/assets/components/asset.tsx +++ b/src/features/assets/components/asset.tsx @@ -158,9 +158,10 @@ const VulnList = ({ }; const issueStatusNames = { - [IssueStatus.ACTIVE]: "Active", - [IssueStatus.FALSE_POSITIVE]: "False Positive", - [IssueStatus.REMEDIATED]: "Remediated", + [IssueStatus.NOT_AFFECTED]: "Not Affected", + [IssueStatus.AFFECTED]: "Active", + [IssueStatus.FIXED]: "Remediated", + [IssueStatus.UNDER_INVESTIGATION]: "Under Investigation", }; interface TabulatedVulnListProps { @@ -172,7 +173,8 @@ const TabulatedVulnList = ({ assetId }: TabulatedVulnListProps) => { const [currentTab, setCurrentTab] = useState(params.issueStatus); const handleUpdateTab = (newStatus: string) => { - const issueStatus = IssueStatus[newStatus as keyof typeof IssueStatus]; + const issueStatus = + IssueStatus[newStatus as keyof typeof IssueStatus]; if (issueStatus === undefined) { return; } @@ -204,23 +206,29 @@ const TabulatedVulnList = ({ assetId }: TabulatedVulnListProps) => { return 1; }; - const aResult = useSuspenseIssuesByAssetId({ + const naResult = useSuspenseIssuesByAssetId({ assetId, - issueStatus: IssueStatus.ACTIVE, + issueStatus: IssueStatus.NOT_AFFECTED, }); - const fpResult = useSuspenseIssuesByAssetId({ + const aResult = useSuspenseIssuesByAssetId({ assetId, - issueStatus: IssueStatus.FALSE_POSITIVE, + issueStatus: IssueStatus.AFFECTED, }); const rResult = useSuspenseIssuesByAssetId({ assetId, - issueStatus: IssueStatus.REMEDIATED, + issueStatus: IssueStatus.FIXED, + }); + const uiResult = useSuspenseIssuesByAssetId({ + assetId, + issueStatus: IssueStatus.UNDER_INVESTIGATION, }); + // Order must match Object.values(IssueStatus) (NOT_AFFECTED, AFFECTED, + // FIXED, UNDER_INVESTIGATION) since results are indexed positionally below. const results: PaginatedResponse<{ vulnerability: Vulnerability } & Issue>[] = []; let showTabs = false; - for (const res of [aResult, fpResult, rResult]) { + for (const res of [naResult, aResult, rResult, uiResult]) { results.push(res.data); if (res.data.totalCount > 0) { showTabs = true; diff --git a/src/features/assets/components/dashboard-columns.tsx b/src/features/assets/components/dashboard-columns.tsx index 33d5372c..4c072ab5 100644 --- a/src/features/assets/components/dashboard-columns.tsx +++ b/src/features/assets/components/dashboard-columns.tsx @@ -26,12 +26,13 @@ function countActiveBySeverity( ): number { return issues.filter( (i) => - i.status === IssueStatus.ACTIVE && i.vulnerability.severity === severity, + i.status === IssueStatus.AFFECTED && + i.vulnerability.severity === severity, ).length; } function totalActiveIssues(issues: AssetIssue[]): number { - return issues.filter((i) => i.status === IssueStatus.ACTIVE).length; + return issues.filter((i) => i.status === IssueStatus.AFFECTED).length; } const SEVERITY_COL_COUNT = 4; @@ -85,7 +86,7 @@ function createSeverityColumn( function countUniqueRemediations(issues: AssetIssue[]): number { const ids = new Set(); for (const issue of issues) { - if (issue.status === IssueStatus.ACTIVE) { + if (issue.status === IssueStatus.AFFECTED) { ids.add(issue.vulnerabilityId); } } diff --git a/src/features/assets/params.ts b/src/features/assets/params.ts index c7d59394..2acabe24 100644 --- a/src/features/assets/params.ts +++ b/src/features/assets/params.ts @@ -16,7 +16,9 @@ for (const status of Object.values(IssueStatus)) { export const assetDetailParams = { ...issueStatusPageParams, - issueStatus: parseAsStringEnum(Object.values(IssueStatus)) - .withDefault(IssueStatus.ACTIVE) + issueStatus: parseAsStringEnum( + Object.values(IssueStatus), + ) + .withDefault(IssueStatus.AFFECTED) .withOptions({ clearOnDefault: true }), }; diff --git a/src/features/assets/server/routers.ts b/src/features/assets/server/routers.ts index 5dfc1675..7070e104 100644 --- a/src/features/assets/server/routers.ts +++ b/src/features/assets/server/routers.ts @@ -217,7 +217,7 @@ export const assetsRouter = createTRPCRouter({ function activeBySeverity(asset: AssetRow, severity: Severity): number { return asset.issues.filter( (i) => - i.status === IssueStatus.ACTIVE && + i.status === IssueStatus.AFFECTED && i.vulnerability.severity === severity, ).length; } @@ -227,7 +227,7 @@ export const assetsRouter = createTRPCRouter({ let total = 0; for (const issue of asset.issues) { if ( - issue.status === IssueStatus.ACTIVE && + issue.status === IssueStatus.AFFECTED && !seen.has(issue.vulnerabilityId) ) { seen.add(issue.vulnerabilityId); @@ -283,7 +283,7 @@ export const assetsRouter = createTRPCRouter({ severities.map((s) => prisma.issue.count({ where: { - status: IssueStatus.ACTIVE, + status: IssueStatus.AFFECTED, vulnerability: { severity: s }, }, }), @@ -293,7 +293,7 @@ export const assetsRouter = createTRPCRouter({ severities.map((s) => prisma.issue.count({ where: { - status: IssueStatus.ACTIVE, + status: IssueStatus.AFFECTED, vulnerability: { severity: s, remediations: { some: {} } }, }, }), @@ -303,7 +303,7 @@ export const assetsRouter = createTRPCRouter({ severities.map((s) => prisma.issue.count({ where: { - status: IssueStatus.REMEDIATED, + status: IssueStatus.FIXED, vulnerability: { severity: s }, }, }), @@ -552,7 +552,7 @@ export const assetsRouter = createTRPCRouter({ .query(async ({ input }) => { const where = { createdAt: { gte: input.createdAfter }, - issues: { some: { status: IssueStatus.ACTIVE } }, + issues: { some: { status: IssueStatus.AFFECTED } }, }; const [totalCount, items] = await Promise.all([ prisma.asset.count({ where }), diff --git a/src/features/chat/utils.ts b/src/features/chat/utils.ts index 8f72268d..595c07bc 100644 --- a/src/features/chat/utils.ts +++ b/src/features/chat/utils.ts @@ -65,7 +65,7 @@ interface VulnerabilityForMarkdown { remediations?: Array<{ id: string; description?: string | null }>; issues?: Array<{ status: string; - asset: { id: string; hostname?: string | null; ip?: string | null }; + asset: { id: string; hostname?: string | null; ip?: string | null } | null; }>; } @@ -79,7 +79,11 @@ interface RemediationForMarkdown { issueRemediations?: Array<{ issue: { status: string; - asset: { id: string; hostname?: string | null; ip?: string | null }; + asset: { + id: string; + hostname?: string | null; + ip?: string | null; + } | null; }; }>; artifacts?: Array<{ @@ -220,6 +224,10 @@ export function vulnerabilityToMarkdown( if (opts.includeAssets && v.issues && v.issues.length > 0) { lines.push(`- **Affected Assets** (${v.issues.length}):`); for (const issue of v.issues) { + if (!issue.asset) { + lines.push(` - device group — issue status: ${issue.status}`); + continue; + } const label = issue.asset.hostname ?? issue.asset.ip ?? issue.asset.id; lines.push( ` - ${label} (${shortId(issue.asset.id)}) — issue status: ${issue.status}`, @@ -253,6 +261,10 @@ export function remediationToMarkdown(r: RemediationForMarkdown): string { if (r.issueRemediations && r.issueRemediations.length > 0) { lines.push(`- **Affected Assets** (${r.issueRemediations.length}):`); for (const ir of r.issueRemediations) { + if (!ir.issue.asset) { + lines.push(` - device group — issue status: ${ir.issue.status}`); + continue; + } const label = ir.issue.asset.hostname ?? ir.issue.asset.ip ?? ir.issue.asset.id; lines.push( diff --git a/src/features/chat/viper-agent/tools/get-recommendations-context.ts b/src/features/chat/viper-agent/tools/get-recommendations-context.ts index c00b49ec..434112ef 100644 --- a/src/features/chat/viper-agent/tools/get-recommendations-context.ts +++ b/src/features/chat/viper-agent/tools/get-recommendations-context.ts @@ -30,7 +30,9 @@ function generateInventorySummaryTable(assets: AssetForContext[]): string { const rows = assets.map((a) => { const label = a.hostname ?? a.ip ?? shortId(a.id); const device = deviceGroupLabel(a.deviceGroup); - const active = (a.issues ?? []).filter((i) => i.status === "ACTIVE").length; + const active = (a.issues ?? []).filter( + (i) => i.status === "AFFECTED", + ).length; return `| ${label} (${shortId(a.id)}) | ${device} | ${a.role ?? "—"} | ${a.status ?? "—"} | ${active} |`; }); @@ -53,7 +55,11 @@ function generateVulnAssetRemMap( const assetLabels = v.issues && v.issues.length > 0 ? v.issues - .map((i) => i.asset.hostname ?? i.asset.ip ?? shortId(i.asset.id)) + .map((i) => + i.asset + ? (i.asset.hostname ?? i.asset.ip ?? shortId(i.asset.id)) + : "device group", + ) .join(", ") : "—"; const remLabels = diff --git a/src/features/issues/components/issue.tsx b/src/features/issues/components/issue.tsx index a4b42897..2532b3e5 100644 --- a/src/features/issues/components/issue.tsx +++ b/src/features/issues/components/issue.tsx @@ -65,8 +65,16 @@ export const IssueDetailPage = ({ id }: { id: string }) => { -

Asset

- + {issue.data.asset ? ( + <> +

Asset

+ + + ) : ( +

+ This issue applies to a device group, not a single asset. +

+ )}

Vulnerability

@@ -103,7 +111,7 @@ export const IssuesSidebarList = ({ ); const nonActiveIssuesCount = Object.values(IssueStatus) - .filter((status) => status !== IssueStatus.ACTIVE) + .filter((status) => status !== IssueStatus.AFFECTED) .map((issueStatus) => ({ issueStatus, count: issues.filter((i) => i.status === issueStatus).length, @@ -158,7 +166,7 @@ export const IssuesSidebarList = ({ <>

- {getAssetRoleLabel(asset)} + {asset ? getAssetRoleLabel(asset) : "Device Group"}

{locationParts.length > 0 && (

@@ -183,11 +191,13 @@ export const IssuesSidebarList = ({ href: `/vulnerabilities/${issue.vulnerabilityId}`, className: "cursor-pointer", }, - type === "assets" && { - label: "Go to Asset Details", - href: `/assets/${issue.assetId}`, - className: "cursor-pointer", - }, + type === "assets" && issue.assetId + ? { + label: "Go to Asset Details", + href: `/assets/${issue.assetId}`, + className: "cursor-pointer", + } + : false, ]} /> @@ -196,7 +206,7 @@ export const IssuesSidebarList = ({ })} - {isIssuesOverflow && ( + {isIssuesOverflow && assetId && (

Viewing {ACTIVE_ISSUES_SHOWN_MAX} of {issues.length} Active Issues @@ -212,7 +222,7 @@ export const IssuesSidebarList = ({

)} - {nonActiveIssuesCount.length > 0 && ( + {nonActiveIssuesCount.length > 0 && assetId && (
Non-Active Issues
{nonActiveIssuesCount.map(({ issueStatus, count }) => ( diff --git a/src/features/issues/hooks/use-issues.ts b/src/features/issues/hooks/use-issues.ts index f27e9f95..a22c3b4f 100644 --- a/src/features/issues/hooks/use-issues.ts +++ b/src/features/issues/hooks/use-issues.ts @@ -22,11 +22,13 @@ export const useUpdateIssueStatus = () => { queryClient.invalidateQueries( trpc.issues.getOne.queryFilter({ id: data.id }), ); - queryClient.invalidateQueries( - trpc.issues.getManyInternalByStatusAndAssetId.queryFilter({ - assetId: data.assetId, - }), - ); + if (data.assetId) { + queryClient.invalidateQueries( + trpc.issues.getManyInternalByStatusAndAssetId.queryFilter({ + assetId: data.assetId, + }), + ); + } queryClient.invalidateQueries( trpc.assets.getIssueMetricsInternal.queryFilter(), ); diff --git a/src/features/tracking/types.ts b/src/features/tracking/types.ts index c29b0de9..b1fc4dbc 100644 --- a/src/features/tracking/types.ts +++ b/src/features/tracking/types.ts @@ -346,7 +346,7 @@ const detailLinkedRemediationSchema = z.object({ const ticketIssueSchema = z.object({ id: z.string(), status: z.enum(IssueStatus), - assetId: z.string(), + assetId: z.string().nullable(), vulnerabilityId: z.string(), }); diff --git a/src/features/vulnerabilities/components/prioritized-columns.tsx b/src/features/vulnerabilities/components/prioritized-columns.tsx index 20920970..b2acb1d7 100644 --- a/src/features/vulnerabilities/components/prioritized-columns.tsx +++ b/src/features/vulnerabilities/components/prioritized-columns.tsx @@ -112,7 +112,10 @@ export const issueColumns: ColumnDef[] = [ { accessorKey: "asset", header: "Affected Asset", - cell: ({ row }) => getAssetRoleLabel(row.original.asset), + cell: ({ row }) => + row.original.asset + ? getAssetRoleLabel(row.original.asset) + : "Device Group", }, { accessorKey: "status", @@ -133,10 +136,12 @@ export const issueColumns: ColumnDef[] = [ cell: ({ row }) => ( diff --git a/src/lib/prisma-client-extensions.ts b/src/lib/prisma-client-extensions.ts index fa4598c8..9d79485f 100644 --- a/src/lib/prisma-client-extensions.ts +++ b/src/lib/prisma-client-extensions.ts @@ -2,7 +2,10 @@ import { Prisma, TriggerEnum } from "@/generated/prisma"; import type { PayloadToResult } from "@/generated/prisma/runtime/library"; import { inngest } from "@/inngest/client"; import prisma from "./db"; -import { deviceGroupWhereForMatching, resolveMatches } from "./device-matching"; +import { + deviceGroupWhereForMatching, + matchingAppliesToDeviceGroup, +} from "./device-matching"; import { getBaseUrl } from "./url-utils"; import { sendWebhook } from "./utils"; @@ -82,11 +85,13 @@ export const vulnerabilityExtension = Prisma.defineExtension((client) => // cast id to string. we know a string exists since create succeeded const vulnerabilityId = vulnerability.id as string; - // Resolve this vuln's matchings to concrete device groups, then find - // every asset in those groups and open issues for them. + // Resolve this vuln's matchings to concrete device groups, then open + // one issue per matching that actually affects a real asset (not one + // issue per asset). const matchings = await client.deviceGroupMatching.findMany({ where: { vulnerabilities: { some: { id: vulnerabilityId } } }, select: { + id: true, vendorId: true, productId: true, versionId: true, @@ -105,25 +110,36 @@ export const vulnerabilityExtension = Prisma.defineExtension((client) => version: { select: { canonicalName: true } }, }, }); - const matchedGroupIds = resolveMatches( - matchings, - candidateGroups, - ).map((group) => group.id); - if (matchedGroupIds.length > 0) { - const assets = await client.asset.findMany({ - where: { deviceGroupId: { in: matchedGroupIds } }, - select: { id: true }, - }); + const groupsWithAssets = new Set( + ( + await client.asset.findMany({ + where: { + deviceGroupId: { in: candidateGroups.map((g) => g.id) }, + }, + select: { deviceGroupId: true }, + distinct: ["deviceGroupId"], + }) + ).map((asset) => asset.deviceGroupId), + ); + + const affectedMatchingIds = matchings + .filter((matching) => + candidateGroups.some( + (group) => + groupsWithAssets.has(group.id) && + matchingAppliesToDeviceGroup(matching, group), + ), + ) + .map((matching) => matching.id); - if (assets.length > 0) { - await client.issue.createMany({ - data: assets.map((asset) => ({ - vulnerabilityId, - assetId: asset.id, - })), - }); - } + if (affectedMatchingIds.length > 0) { + await client.issue.createMany({ + data: affectedMatchingIds.map((deviceGroupMatchingId) => ({ + vulnerabilityId, + deviceGroupMatchingId, + })), + }); } } From 44249967ddbf3c0d8825b7c5be4f349a2d3a9b45 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Wed, 1 Jul 2026 18:45:15 -0400 Subject: [PATCH 02/12] no longer seed issues created only by prisma client extension --- prisma/seed.ts | 48 ++---------------------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 5b720723..f3754702 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -48,8 +48,7 @@ const SAMPLE_DEVICE_GROUPS = [ version: "unknown", }, // ── EOL OS platforms (exist for vulnerability targeting; no assets use these - // as primary CPE, but EternalBlue affectedDeviceGroups connects here too - // so seedIssues creates Issue records for all Windows hosts) ────────────── + // as primary CPE, but EternalBlue affectedDeviceGroups connects here too) { cpe: "cpe:2.3:o:microsoft:windows_7:-:*:*:*:*:*:*:*", manufacturer: "Microsoft", @@ -568,7 +567,7 @@ const SAMPLE_VULNERABILITIES = [ ], }, // Connects to both the EOL OS device groups AND the application-level device - // groups so seedIssues() creates Issue records for every affected asset + // groups cpes: [ "cpe:2.3:o:microsoft:windows_7:-:*:*:*:*:*:*:*", "cpe:2.3:o:microsoft:windows_server_2008:r2:sp1:*:*:*:*:x64:*", @@ -1552,48 +1551,6 @@ async function seedRemediations(userId: string) { return successfulRemediations; } -async function seedIssues() { - console.log("\n🌱 Seeding issues (linking assets to vulnerabilities)..."); - - const assets = await prisma.asset.findMany({ - include: { deviceGroup: true }, - }); - - const vulnerabilities = await prisma.vulnerability.findMany({ - include: { deviceGroupMatchings: true }, - }); - - const issues = []; - - for (const asset of assets) { - for (const vulnerability of vulnerabilities) { - const isAffected = vulnerability.deviceGroupMatchings.some((m) => - matchingMatchesGroup(m, asset.deviceGroup), - ); - - if (isAffected) { - try { - const issue = await prisma.issue.create({ - data: { - assetId: asset.id, - vulnerabilityId: vulnerability.id, - status: "AFFECTED", - }, - }); - issues.push(issue); - } catch (_error) { - console.warn( - `⚠️ Issue already exists for asset ${asset.id} and vulnerability ${vulnerability.id}`, - ); - } - } - } - } - - console.log(`✅ Seeded ${issues.length} issues`); - return issues; -} - async function seedMemories(userId: string) { console.log("\n🌱 Seeding memories..."); @@ -2080,7 +2037,6 @@ async function main() { await seedVulnerabilities(user.id); await seedDeviceArtifacts(user.id); await seedRemediations(user.id); - await seedIssues(); await seedWorkflows(user.id); await seedMemories(user.id); await seedWorkOrderTickets(user.id); From 01d0a9b1fb75402e0af5c47ebe58059fa7d72ebc Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Wed, 1 Jul 2026 19:41:29 -0400 Subject: [PATCH 03/12] VEX agent, prisma client extensions --- .../inbox/agent/__tests__/vex.test.ts | 185 +++++ src/features/inbox/agent/match.ts | 9 +- src/features/inbox/agent/vex.ts | 705 ++++++++++++++++++ src/inngest/functions/process-inbox-email.ts | 17 +- src/lib/prisma-client-extensions.ts | 66 +- src/lib/string-utils.ts | 34 + src/test/server-only-stub.ts | 5 + vitest.config.mts | 2 + 8 files changed, 961 insertions(+), 62 deletions(-) create mode 100644 src/features/inbox/agent/__tests__/vex.test.ts create mode 100644 src/features/inbox/agent/vex.ts create mode 100644 src/test/server-only-stub.ts diff --git a/src/features/inbox/agent/__tests__/vex.test.ts b/src/features/inbox/agent/__tests__/vex.test.ts new file mode 100644 index 00000000..55d70f0d --- /dev/null +++ b/src/features/inbox/agent/__tests__/vex.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { planVexWrites, type VexContext, type VexResult } from "../vex"; + +// A notification linking one vuln whose matching resolves to a device group with +// two assets, plus a second vuln/issue with no assets. +const context: VexContext = { + notificationId: "notif_1", + markdown: "", + issues: [ + { + issueId: "issue_group_a", + vulnerabilityId: "vuln_a", + assetIds: ["asset_reachable", "asset_unreachable"], + }, + { + issueId: "issue_group_b", + vulnerabilityId: "vuln_b", + assetIds: [], + }, + ], +}; + +describe("planVexWrites", () => { + it("marks an issue NOT_AFFECTED with a justification when a feature is absent", () => { + const result: VexResult = { + issue_group_a: { + status: { + status: "NOT_AFFECTED", + justification: "COMPONENT_NOT_PRESENT", + }, + reasonWhy: "Note says the affected feature is not enabled.", + confidence: "Matched", + }, + }; + + const { issueUpdates, assetOverrides } = planVexWrites(context, result); + expect(assetOverrides).toEqual([]); + expect(issueUpdates).toEqual([ + { + issueId: "issue_group_a", + status: "NOT_AFFECTED", + justification: "COMPONENT_NOT_PRESENT", + confidence: "Matched", + notes: "Note says the affected feature is not enabled.", + }, + ]); + }); + + it("creates an asset override and leaves the group issue untouched when only one asset differs", () => { + const result: VexResult = { + issue_group_a: { + // no group status => group issue unchanged + assets: [ + { + id: "asset_unreachable", + status: { + status: "NOT_AFFECTED", + justification: "HOSPITAL_COMPENSATING_CONTROL", + }, + reasonWhy: "Asset is not reachable over the network.", + }, + ], + }, + }; + + const { issueUpdates, assetOverrides } = planVexWrites(context, result); + expect(issueUpdates).toEqual([]); + expect(assetOverrides).toEqual([ + { + assetId: "asset_unreachable", + vulnerabilityId: "vuln_a", + status: "NOT_AFFECTED", + justification: "HOSPITAL_COMPENSATING_CONTROL", + confidence: "NeedsReview", // defaulted when no group confidence supplied + notes: "Asset is not reachable over the network.", + }, + ]); + }); + + it("marks an issue UNDER_INVESTIGATION when device detail is insufficient", () => { + const result: VexResult = { + issue_group_b: { + status: { status: "UNDER_INVESTIGATION" }, + reasonWhy: "Not enough detail about the device firmware.", + confidence: "NeedsReview", + }, + }; + + const { issueUpdates } = planVexWrites(context, result); + expect(issueUpdates).toEqual([ + { + issueId: "issue_group_b", + status: "UNDER_INVESTIGATION", + justification: null, + confidence: "NeedsReview", + notes: "Not enough detail about the device firmware.", + }, + ]); + }); + + it("supports a group update and an asset override together", () => { + const result: VexResult = { + issue_group_a: { + status: { + status: "NOT_AFFECTED", + justification: "VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY", + }, + reasonWhy: "Exploit path unreachable on this device class.", + confidence: "Matched", + assets: [ + { + id: "asset_reachable", + status: { status: "UNDER_INVESTIGATION" }, + reasonWhy: "This one asset has an unusual config.", + }, + ], + }, + }; + + const { issueUpdates, assetOverrides } = planVexWrites(context, result); + expect(issueUpdates).toHaveLength(1); + expect(assetOverrides).toEqual([ + { + assetId: "asset_reachable", + vulnerabilityId: "vuln_a", + status: "UNDER_INVESTIGATION", + justification: null, + confidence: "Matched", + notes: "This one asset has an unusual config.", + }, + ]); + }); + + it("skips hallucinated issue ids not present in context", () => { + const result: VexResult = { + not_a_real_issue: { + status: { status: "AFFECTED" }, + reasonWhy: "x", + confidence: "Matched", + }, + }; + expect(planVexWrites(context, result)).toEqual({ + issueUpdates: [], + assetOverrides: [], + }); + }); + + it("skips NOT_AFFECTED without a justification", () => { + const result = { + issue_group_a: { + // deliberately missing justification (bypasses the zod guard) + status: { status: "NOT_AFFECTED" }, + reasonWhy: "x", + confidence: "Matched", + }, + } as unknown as VexResult; + expect(planVexWrites(context, result).issueUpdates).toEqual([]); + }); + + it("skips asset overrides for assets not reachable through the issue", () => { + const result: VexResult = { + issue_group_a: { + assets: [ + { + id: "asset_from_another_group", + status: { + status: "NOT_AFFECTED", + justification: "HOSPITAL_COMPENSATING_CONTROL", + }, + reasonWhy: "x", + }, + ], + }, + }; + expect(planVexWrites(context, result).assetOverrides).toEqual([]); + }); + + it("treats an empty object value as no change", () => { + const result: VexResult = { issue_group_a: {}, issue_group_b: {} }; + expect(planVexWrites(context, result)).toEqual({ + issueUpdates: [], + assetOverrides: [], + }); + }); +}); diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 41437921..f35e0497 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import type { ConfidenceLevel } from "@/generated/prisma"; import prisma from "@/lib/db"; import { cpeToDeviceGroup } from "@/lib/router-utils"; +import { deviceIdentityInline } from "@/lib/string-utils"; import type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -67,15 +68,11 @@ function renderCandidates(candidates: Candidates): string { return candidates.deviceGroups .map((entry, i) => { - const e = entry.extracted; - const extractedLine = `Device group #${i + 1} extracted: cpe=${e.cpe ?? "?"} | manufacturer=${e.manufacturer ?? "?"} | modelName=${e.modelName ?? "?"} | version=${e.version ?? "?"}`; + const extractedLine = `Device group #${i + 1} extracted: ${deviceIdentityInline(entry.extracted)}`; const matches = entry.matches.length > 0 ? entry.matches - .map( - (m) => - ` - id: ${m.id} | cpe: ${m.cpe.join(", ") || "(none)"} | manufacturer: ${m.manufacturer ?? "(none)"} | modelName: ${m.modelName ?? "(none)"} | version: ${m.version ?? "(none)"}`, - ) + .map((m) => ` - id: ${m.id} | ${deviceIdentityInline(m)}`) .join("\n") : " - (no candidates found)"; return `${extractedLine}\n Candidates:\n${matches}`; diff --git a/src/features/inbox/agent/vex.ts b/src/features/inbox/agent/vex.ts new file mode 100644 index 00000000..f216447e --- /dev/null +++ b/src/features/inbox/agent/vex.ts @@ -0,0 +1,705 @@ +// VEX sorting agent: given a notification that has linked vulnerabilities, sort +// each baseline Issue (one per vulnerability × device-group-matching, created by +// vulnerabilityExtension) into "at risk" (AFFECTED), "possibly at risk" +// (UNDER_INVESTIGATION), or "unaffected" (NOT_AFFECTED + a VEX justification), +// using the hospital's notes and compensating controls as evidence. May also +// create asset-level override issues when a single asset differs from its group. + +import "server-only"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { + type ConfidenceLevel, + type IssueStatus, + NotAffectedJustification, +} from "@/generated/prisma"; +import prisma from "@/lib/db"; +import { + deviceGroupWhereForMatching, + type MatchingLike, + matchingAppliesToDeviceGroup, +} from "@/lib/device-matching"; +import { + deviceGroupLabel, + deviceGroupMatchingLabel, + deviceGroupToMarkdown, +} from "@/lib/string-utils"; + +const MODEL = "claude-sonnet-4-6"; + +// ─── Structured output schema ──────────────────────────────────────────────── +// +// The output is keyed by existing baseline Issue cuid (built dynamically per +// request in buildVexSchema) so the model must consider every specific issue; +// an omitted or empty value means "no change". `status` is a discriminated +// union so a justification is required exactly when status is NOT_AFFECTED. + +const statusSchema = z + .discriminatedUnion("status", [ + z.object({ status: z.literal("AFFECTED") }), + z.object({ status: z.literal("UNDER_INVESTIGATION") }), + z.object({ + status: z.literal("NOT_AFFECTED"), + justification: z + .nativeEnum(NotAffectedJustification) + .describe( + "VEX justification — required when the status is NOT_AFFECTED", + ), + }), + ]) + .describe( + "The determination. AFFECTED = at risk, UNDER_INVESTIGATION = possibly at risk (insufficient detail), NOT_AFFECTED = unaffected (must carry a justification).", + ); + +const assetOverrideSchema = z.object({ + id: z + .string() + .describe( + "Asset id — must be one of the asset ids listed under this issue. Only emit an override when this single asset differs from the rest of its device group.", + ), + status: statusSchema, + reasonWhy: z.string().describe("Concise justification for this asset."), +}); + +const issueValueSchema = z.object({ + status: statusSchema + .nullish() + .describe( + "The device-group-level determination. Omit to leave the group issue unchanged while still flagging an asset exception below.", + ), + reasonWhy: z + .string() + .nullish() + .describe("Concise reasoning for the group-level status, if you set one."), + confidence: z + .enum(["NeedsReview", "Matched"]) + .nullish() + .describe( + "Matched = strong evidence; NeedsReview = plausible but a human should verify. Never Confirmed (human-only).", + ), + assets: z + .array(assetOverrideSchema) + .nullish() + .describe( + "Asset-level overrides. Leave empty unless a specific asset differs from its device group (e.g. a note on that asset id).", + ), +}); + +/** Build the per-request tool schema: one optional property per baseline issue id. */ +function buildVexSchema(issueIds: string[]) { + return z.object( + Object.fromEntries(issueIds.map((id) => [id, issueValueSchema.optional()])), + ); +} + +// Loose types (independent of the dynamic zod schema) used by the deterministic +// planner so it can be unit-tested without constructing the schema. +type StatusValue = { + status: IssueStatus; + justification?: NotAffectedJustification | null; +}; +type AssetOverrideValue = { + id: string; + status: StatusValue; + reasonWhy: string; +}; +type IssueValue = { + status?: StatusValue; + reasonWhy?: string; + confidence?: "NeedsReview" | "Matched"; + assets?: AssetOverrideValue[] | null; +}; +export type VexResult = Record; + +// ─── Context ───────────────────────────────────────────────────────────────── + +/** A baseline (device-group-matching-level) issue the agent may refine. */ +export type VexIssueContext = { + issueId: string; + vulnerabilityId: string; + /** Asset ids reachable through this issue's matching — valid targets for overrides. */ + assetIds: string[]; +}; + +export type VexContext = { + notificationId: string; + /** Full markdown prompt describing sources, vulns, remediations, groups, notes, issues. */ + markdown: string; + /** Baseline issues in scope, in the order rendered. */ + issues: VexIssueContext[]; +}; + +type MatchingWithRefs = MatchingLike & { + id: string; + vendor?: { canonicalDisplayName: string } | null; + product?: { canonicalDisplayName: string } | null; + version?: { canonicalDisplayName: string } | null; +}; + +/** + * Gather everything the VEX agent needs for a notification and render it to a + * single markdown prompt. Deterministic (no LLM); separated so gather + render + * can be inspected independently of the model call. + */ +export async function gatherVexContext( + notificationId: string, +): Promise { + const notification = await prisma.notification.findUnique({ + where: { id: notificationId }, + include: { + sources: { select: { markdown: true, channel: true } }, + vulnerabilities: { + include: { + vulnerability: { + include: { + deviceGroupMatchings: { + include: { + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { select: { canonicalDisplayName: true } }, + }, + }, + issues: { where: { deviceGroupMatchingId: { not: null } } }, + }, + }, + }, + }, + remediations: { + include: { + remediation: { + include: { + deviceGroupMatchings: { + include: { + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { select: { canonicalDisplayName: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!notification) return null; + + const vulnerabilities = notification.vulnerabilities.map( + (m) => m.vulnerability, + ); + if (vulnerabilities.length === 0) return null; + + const remediations = notification.remediations.map((m) => m.remediation); + + // All matchings referenced by the linked vulns (+ remediations, for notes). + const matchingsById = new Map(); + for (const v of vulnerabilities) { + for (const dgm of v.deviceGroupMatchings) matchingsById.set(dgm.id, dgm); + } + for (const r of remediations) { + for (const dgm of r.deviceGroupMatchings) matchingsById.set(dgm.id, dgm); + } + const matchings = [...matchingsById.values()]; + + // Resolve matchings → concrete device groups (with their assets) in one query. + const candidateGroups = + matchings.length > 0 + ? await prisma.deviceGroup.findMany({ + where: { OR: matchings.map(deviceGroupWhereForMatching) }, + select: { + id: true, + vendorId: true, + productId: true, + versionId: true, + cpe: true, + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { + select: { canonicalName: true, canonicalDisplayName: true }, + }, + assets: { + select: { id: true, hostname: true, ip: true }, + }, + }, + }) + : []; + + // matchingId → resolved device groups + const groupsByMatching = new Map(); + for (const matching of matchings) { + groupsByMatching.set( + matching.id, + candidateGroups.filter((g) => matchingAppliesToDeviceGroup(matching, g)), + ); + } + + // Baseline issues in scope: one per (vuln, matching) with a device-group match. + const issues: VexIssueContext[] = []; + type IssueRender = { + issueId: string; + cve: string; + matching: MatchingWithRefs; + groups: typeof candidateGroups; + assetIds: string[]; + status: string; + }; + const issueRenders: IssueRender[] = []; + + for (const v of vulnerabilities) { + for (const issue of v.issues) { + if (!issue.deviceGroupMatchingId) continue; + const matching = matchingsById.get(issue.deviceGroupMatchingId); + if (!matching) continue; + const groups = groupsByMatching.get(matching.id) ?? []; + const assetIds = [ + ...new Set(groups.flatMap((g) => g.assets.map((a) => a.id))), + ]; + issues.push({ issueId: issue.id, vulnerabilityId: v.id, assetIds }); + issueRenders.push({ + issueId: issue.id, + cve: v.cveId ?? v.id, + matching, + groups, + assetIds, + status: issue.status, + }); + } + } + + if (issues.length === 0) return null; + + // Notes: direct (targetModel + instanceId points at an in-scope entity) plus + // all PERSISTENT notes (which always apply to the hospital). + const groupIds = candidateGroups.map((g) => g.id); + const allAssetIds = [ + ...new Set(candidateGroups.flatMap((g) => g.assets.map((a) => a.id))), + ]; + const notes = await prisma.note.findMany({ + where: { + OR: [ + { status: "PERSISTENT" }, + { + targetModel: "VULNERABILITY", + instanceId: { in: vulnerabilities.map((v) => v.id) }, + }, + { + targetModel: "REMEDIATION", + instanceId: { in: remediations.map((r) => r.id) }, + }, + { targetModel: "DEVICE_GROUP", instanceId: { in: groupIds } }, + { + targetModel: "DEVICE_GROUP_MATCHING", + instanceId: { in: matchings.map((m) => m.id) }, + }, + { targetModel: "ASSET", instanceId: { in: allAssetIds } }, + ], + }, + select: { text: true, status: true, targetModel: true, instanceId: true }, + }); + + // Label lookups for rendering note targets. + const assetLabel = new Map(); + for (const g of candidateGroups) { + for (const a of g.assets) assetLabel.set(a.id, a.hostname ?? a.ip ?? a.id); + } + const groupLabel = new Map( + candidateGroups.map((g) => [g.id, deviceGroupLabel(g)]), + ); + const matchingLabel = new Map( + matchings.map((m) => [m.id, deviceGroupMatchingLabel(m)]), + ); + const cveById = new Map(vulnerabilities.map((v) => [v.id, v.cveId ?? v.id])); + + const markdown = renderVexPrompt({ + sources: notification.sources, + vulnerabilities, + remediations, + candidateGroups, + issueRenders, + notes, + labels: { assetLabel, groupLabel, matchingLabel, cveById }, + }); + + return { notificationId, markdown, issues }; +} + +type NoteRow = { + text: string; + status: string; + targetModel: string | null; + instanceId: string | null; +}; + +function renderNoteTarget( + note: NoteRow, + labels: { + assetLabel: Map; + groupLabel: Map; + matchingLabel: Map; + cveById: Map; + }, +): string { + if (note.status === "PERSISTENT") return "Persistent (hospital-wide)"; + const id = note.instanceId ?? ""; + switch (note.targetModel) { + case "ASSET": + return `Asset ${labels.assetLabel.get(id) ?? id} (id: ${id})`; + case "DEVICE_GROUP": + return `Device group ${labels.groupLabel.get(id) ?? id}`; + case "DEVICE_GROUP_MATCHING": + return `Matching ${labels.matchingLabel.get(id) ?? id}`; + case "VULNERABILITY": + return `Vulnerability ${labels.cveById.get(id) ?? id}`; + case "REMEDIATION": + return `Remediation ${id}`; + default: + return "Unknown target"; + } +} + +function renderVexPrompt(args: { + sources: { markdown: string | null; channel: string }[]; + vulnerabilities: Array<{ + id: string; + cveId: string | null; + severity: string; + cvssScore: number | null; + description: string | null; + narrative: string | null; + impact: string | null; + affectedComponents: string[]; + }>; + remediations: Array<{ + id: string; + description: string | null; + narrative: string | null; + }>; + candidateGroups: Array<{ + id: string; + cpe: string[]; + vendor?: { canonicalDisplayName: string } | null; + product?: { canonicalDisplayName: string } | null; + version?: { canonicalDisplayName: string } | null; + assets: { id: string }[]; + }>; + issueRenders: Array<{ + issueId: string; + cve: string; + matching: MatchingWithRefs; + groups: Array<{ id: string }>; + assetIds: string[]; + status: string; + }>; + notes: NoteRow[]; + labels: { + assetLabel: Map; + groupLabel: Map; + matchingLabel: Map; + cveById: Map; + }; +}): string { + const sections: string[] = []; + + sections.push( + "## Notification sources\n\n" + + (args.sources + .map((s) => s.markdown?.trim()) + .filter(Boolean) + .join("\n\n---\n\n") || "_No source text._"), + ); + + sections.push( + "## Linked vulnerabilities\n\n" + + args.vulnerabilities + .map((v) => { + const lines = [ + `### ${v.cveId ?? v.id} — ${v.severity}${v.cvssScore != null ? ` (CVSS ${v.cvssScore})` : ""}`, + ]; + if (v.affectedComponents.length > 0) + lines.push( + `- **Affected Components**: ${v.affectedComponents.join(", ")}`, + ); + if (v.description) lines.push(`- **Description**: ${v.description}`); + if (v.narrative) + lines.push(`- **Exploit Narrative**: ${v.narrative}`); + if (v.impact) lines.push(`- **Clinical Impact**: ${v.impact}`); + return lines.join("\n"); + }) + .join("\n\n"), + ); + + if (args.remediations.length > 0) { + sections.push( + "## Linked remediations\n\n" + + args.remediations + .map((r) => { + const lines = [`### Remediation ${r.id}`]; + if (r.description) + lines.push(`- **Description**: ${r.description}`); + if (r.narrative) lines.push(`- **How to Apply**: ${r.narrative}`); + return lines.join("\n"); + }) + .join("\n\n"), + ); + } + + sections.push( + "## Linked device groups (with asset counts)\n\n" + + (args.candidateGroups.length > 0 + ? args.candidateGroups + .map( + (g) => + `${deviceGroupToMarkdown(g)}\n- **Assets in this group**: ${g.assets.length}`, + ) + .join("\n\n") + : "_No device groups resolved._"), + ); + + if (args.notes.length > 0) { + sections.push( + "## Notes (evidence)\n\n" + + args.notes + .map((n) => `- **${renderNoteTarget(n, args.labels)}**: ${n.text}`) + .join("\n"), + ); + } + + sections.push( + "## Issues to sort\n\n" + + "Return a determination keyed by these exact issue ids. Omit an id (or return `{}`) to leave it unchanged.\n\n" + + args.issueRenders + .map((r) => { + const groupLabels = r.groups + .map((g) => args.labels.groupLabel.get(g.id) ?? g.id) + .join(", "); + const assets = + r.assetIds.length > 0 + ? r.assetIds + .map( + (id) => + `${args.labels.assetLabel.get(id) ?? id} (id: ${id})`, + ) + .join(", ") + : "none"; + return [ + `- Issue \`${r.issueId}\` — current status ${r.status}`, + ` - Vulnerability: ${r.cve}`, + ` - Device: ${deviceGroupMatchingLabel(r.matching)}${groupLabels ? ` → ${groupLabels}` : ""}`, + ` - Assets (${r.assetIds.length}): ${assets}`, + ].join("\n"); + }) + .join("\n\n"), + ); + + return sections.join("\n\n"); +} + +// ─── System prompt ─────────────────────────────────────────────────────────── + +const SYSTEM_PROMPT = `You are a VEX (Vulnerability Exploitability eXchange) analyst for a hospital cybersecurity platform. A security notification references one or more vulnerabilities, and the platform has already opened one baseline Issue per (vulnerability × affected device group). Your job is to sort each issue into the correct exploitability status using ONLY the evidence provided. + +Statuses: +- AFFECTED ("at risk"): the vulnerability is exploitable on this device group as deployed. +- UNDER_INVESTIGATION ("possibly at risk"): there is not enough detail about the device to decide. Use this when device details are insufficient. +- NOT_AFFECTED ("unaffected"): evidence shows the vulnerability is not exploitable here. You MUST provide a VEX justification: + - COMPONENT_NOT_PRESENT: the affected feature/component is not enabled or installed (e.g. a note says the affected feature is off). + - VULNERABLE_CODE_NOT_PRESENT / VULNERABLE_CODE_NOT_IN_EXECUTE_PATH: the vulnerable code isn't shipped or never runs. + - VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY: the exploit path can't be reached (e.g. log4shell on an infusion pump that never takes adversarial input). + - INLINE_MITIGATIONS_ALREADY_EXIST: a built-in mitigation neutralizes the vuln. + - HOSPITAL_COMPENSATING_CONTROL: the hospital has a compensating control (e.g. an asset is not reachable over the network, which the exploit requires). + - HOSPITAL_ACCEPTS_RISK: the hospital has explicitly accepted the risk. + +Rules: +- Default: a determination applies to the whole device-group issue via "status". +- Only emit an asset-level override (under "assets") when a SPECIFIC asset differs from the rest of its group — e.g. a note is attached to that exact asset id. Never invent asset ids; use only the ids listed under the issue. +- If only one asset is an exception (e.g. one asset is not network-reachable) and the rest of the group is still affected, OMIT the group-level "status" so the device-group issue is left unchanged, and put the exception in "assets". Set the group "status" only when your determination applies to the whole group. +- Ground every decision in the provided sources, vulnerability descriptions, remediations, and notes. Never invent facts or numbers. +- Set confidence to Matched only with strong evidence; otherwise NeedsReview. +- Call the record_vex_determinations tool exactly once with your determinations. Omit issues you are not changing.`; + +// ─── Model call ────────────────────────────────────────────────────────────── + +export async function sortVulnerabilities( + context: VexContext, +): Promise { + const issueIds = context.issues.map((i) => i.issueId); + const schema = buildVexSchema(issueIds); + + const recordTool = tool(async () => "ok", { + name: "record_vex_determinations", + description: + "Record the VEX determination for each issue, keyed by issue id. Omit issues that are unchanged.", + schema, + }); + + // Extended thinking requires tool_choice "auto" (no forcing), so we bind the + // single tool and read the call args instead of using withStructuredOutput. + const model = new ChatAnthropic({ + model: MODEL, + maxTokens: 8000, + thinking: { type: "enabled", budget_tokens: 4000 }, + }).bindTools([recordTool]); + + const res = await model.invoke([ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: context.markdown }, + ]); + + const call = res.tool_calls?.find( + (c) => c.name === "record_vex_determinations", + ); + if (!call) return {}; + + const parsed = schema.safeParse(call.args); + return parsed.success ? (parsed.data as VexResult) : {}; +} + +// ─── Deterministic apply ───────────────────────────────────────────────────── + +export type VexApplySummary = { updated: number; created: number }; + +type IssueUpdateOp = { + issueId: string; + status: IssueStatus; + justification: NotAffectedJustification | null; + confidence: ConfidenceLevel; + notes: string; +}; +type AssetOverrideOp = { + assetId: string; + vulnerabilityId: string; + status: IssueStatus; + justification: NotAffectedJustification | null; + confidence: ConfidenceLevel; + notes: string; +}; + +/** Normalize a status value, dropping NOT_AFFECTED without a justification. */ +function normalizeStatus(status: StatusValue | undefined): { + status: IssueStatus; + justification: NotAffectedJustification | null; +} | null { + if (!status?.status) return null; + if (status.status === "NOT_AFFECTED") { + if (!status.justification) return null; + return { status: "NOT_AFFECTED", justification: status.justification }; + } + return { status: status.status, justification: null }; +} + +/** + * Turn an agent VexResult into deterministic write ops. Pure + guarded (no DB): + * skips ids that aren't baseline issues in context (hallucinations), skips + * NOT_AFFECTED without a justification, and skips asset overrides whose id isn't + * reachable through the issue. Unit-tested in isolation. + */ +export function planVexWrites( + context: VexContext, + result: VexResult, +): { issueUpdates: IssueUpdateOp[]; assetOverrides: AssetOverrideOp[] } { + const issuesById = new Map(context.issues.map((i) => [i.issueId, i])); + const issueUpdates: IssueUpdateOp[] = []; + const assetOverrides: AssetOverrideOp[] = []; + + for (const [issueId, value] of Object.entries(result)) { + const ctx = issuesById.get(issueId); + if (!ctx || !value) continue; // hallucinated id or no-change + + const confidence: ConfidenceLevel = + value.confidence === "Matched" ? "Matched" : "NeedsReview"; + + // Group-level update only when a status was supplied. A determination may + // carry only asset overrides, leaving the device-group issue untouched. + const norm = normalizeStatus(value.status); + if (norm) { + issueUpdates.push({ + issueId, + status: norm.status, + justification: norm.justification, + confidence, + notes: value.reasonWhy ?? "", + }); + } + + for (const override of value.assets ?? []) { + if (!ctx.assetIds.includes(override.id)) continue; // out-of-scope asset + const aNorm = normalizeStatus(override.status); + if (!aNorm) continue; + assetOverrides.push({ + assetId: override.id, + vulnerabilityId: ctx.vulnerabilityId, + status: aNorm.status, + justification: aNorm.justification, + confidence, + notes: override.reasonWhy ?? "", + }); + } + } + + return { issueUpdates, assetOverrides }; +} + +/** Apply planned VEX writes to the database in a single transaction. */ +export async function applyVexDeterminations( + context: VexContext, + result: VexResult, +): Promise { + const { issueUpdates, assetOverrides } = planVexWrites(context, result); + + await prisma.$transaction(async (tx) => { + for (const op of issueUpdates) { + await tx.issue.update({ + where: { id: op.issueId }, + data: { + status: op.status, + notAffectedJustification: op.justification, + statusConfidence: op.confidence, + statusNotes: op.notes, + }, + }); + } + + for (const op of assetOverrides) { + await tx.issue.upsert({ + where: { + assetId_vulnerabilityId: { + assetId: op.assetId, + vulnerabilityId: op.vulnerabilityId, + }, + }, + create: { + assetId: op.assetId, + vulnerabilityId: op.vulnerabilityId, + status: op.status, + notAffectedJustification: op.justification, + statusConfidence: op.confidence, + statusNotes: op.notes, + }, + update: { + status: op.status, + notAffectedJustification: op.justification, + statusConfidence: op.confidence, + statusNotes: op.notes, + }, + }); + } + }); + + return { updated: issueUpdates.length, created: assetOverrides.length }; +} + +/** + * End-to-end entry point used by the inbox pipeline: gather context, run the + * agent, and apply the result. Returns null when the notification has no linked + * vulnerabilities / issues to sort. + */ +export async function sortNotificationVulnerabilities( + notificationId: string, +): Promise<(VexApplySummary & { issues: number }) | null> { + const context = await gatherVexContext(notificationId); + if (!context) return null; + const result = await sortVulnerabilities(context); + const summary = await applyVexDeterminations(context, result); + return { ...summary, issues: context.issues.length }; +} diff --git a/src/inngest/functions/process-inbox-email.ts b/src/inngest/functions/process-inbox-email.ts index d218274e..7fd120f3 100644 --- a/src/inngest/functions/process-inbox-email.ts +++ b/src/inngest/functions/process-inbox-email.ts @@ -12,6 +12,7 @@ import { stripHtml, } from "@/features/inbox/agent/relevance"; import { triageNotification } from "@/features/inbox/agent/triage"; +import { sortNotificationVulnerabilities } from "@/features/inbox/agent/vex"; import { Prisma } from "@/generated/prisma"; import prisma from "@/lib/db"; import { normalizeMd5, uploadBufferToS3 } from "@/lib/s3"; @@ -237,7 +238,19 @@ export const processInboxEmail = inngest.createFunction( return matchAndLinkEntities(notificationId, extracted, candidates); }); - // 9. Triage: assign priority, reason, and hospital impact + // 9. VEX sort: if the notification has linked vulnerabilities, sort each + // baseline Issue into at-risk / possibly-at-risk / unaffected. Runs before + // triage so priority/hospital-impact reasoning can reflect the results. + const vexSummary = await step.run("sort-vulnerabilities", async () => { + if (!notificationId) return { vexSkipped: true as const }; + const vulnCount = await prisma.notificationVulnerabilityMapping.count({ + where: { notificationId }, + }); + if (vulnCount === 0) return { vexSkipped: true as const }; + return sortNotificationVulnerabilities(notificationId); + }); + + // 10. Triage: assign priority, reason, and hospital impact if (notificationId) { await step.run("triage-notification", async () => { const result = await triageNotification(sourceId, notificationId); @@ -252,6 +265,6 @@ export const processInboxEmail = inngest.createFunction( }); } - return { sourceId, notificationId, emailId, linkSummary }; + return { sourceId, notificationId, emailId, linkSummary, vexSummary }; }, ); diff --git a/src/lib/prisma-client-extensions.ts b/src/lib/prisma-client-extensions.ts index 9d79485f..bd8567fa 100644 --- a/src/lib/prisma-client-extensions.ts +++ b/src/lib/prisma-client-extensions.ts @@ -2,10 +2,6 @@ import { Prisma, TriggerEnum } from "@/generated/prisma"; import type { PayloadToResult } from "@/generated/prisma/runtime/library"; import { inngest } from "@/inngest/client"; import prisma from "./db"; -import { - deviceGroupWhereForMatching, - matchingAppliesToDeviceGroup, -} from "./device-matching"; import { getBaseUrl } from "./url-utils"; import { sendWebhook } from "./utils"; @@ -85,62 +81,24 @@ export const vulnerabilityExtension = Prisma.defineExtension((client) => // cast id to string. we know a string exists since create succeeded const vulnerabilityId = vulnerability.id as string; - // Resolve this vuln's matchings to concrete device groups, then open - // one issue per matching that actually affects a real asset (not one - // issue per asset). + // Open one baseline issue per DeviceGroupMatching linked to this + // vulnerability, regardless of whether any assets exist yet. The VEX + // sorting agent later refines these (status/justification) and may add + // asset-level override issues. skipDuplicates keeps replays idempotent + // (issues are unique per [deviceGroupMatchingId, vulnerabilityId]). const matchings = await client.deviceGroupMatching.findMany({ where: { vulnerabilities: { some: { id: vulnerabilityId } } }, - select: { - id: true, - vendorId: true, - productId: true, - versionId: true, - versionRange: true, - }, + select: { id: true }, }); if (matchings.length > 0) { - const candidateGroups = await client.deviceGroup.findMany({ - where: { OR: matchings.map(deviceGroupWhereForMatching) }, - select: { - id: true, - vendorId: true, - productId: true, - versionId: true, - version: { select: { canonicalName: true } }, - }, + await client.issue.createMany({ + data: matchings.map((matching) => ({ + vulnerabilityId, + deviceGroupMatchingId: matching.id, + })), + skipDuplicates: true, }); - - const groupsWithAssets = new Set( - ( - await client.asset.findMany({ - where: { - deviceGroupId: { in: candidateGroups.map((g) => g.id) }, - }, - select: { deviceGroupId: true }, - distinct: ["deviceGroupId"], - }) - ).map((asset) => asset.deviceGroupId), - ); - - const affectedMatchingIds = matchings - .filter((matching) => - candidateGroups.some( - (group) => - groupsWithAssets.has(group.id) && - matchingAppliesToDeviceGroup(matching, group), - ), - ) - .map((matching) => matching.id); - - if (affectedMatchingIds.length > 0) { - await client.issue.createMany({ - data: affectedMatchingIds.map((deviceGroupMatchingId) => ({ - vulnerabilityId, - deviceGroupMatchingId, - })), - }); - } } inngest diff --git a/src/lib/string-utils.ts b/src/lib/string-utils.ts index f94a9d68..94aff9f7 100644 --- a/src/lib/string-utils.ts +++ b/src/lib/string-utils.ts @@ -58,6 +58,40 @@ export function deviceGroupCpeList(dg: DeviceGroupDisplay): string { return (dg.cpe ?? []).join(", "); } +/** + * Markdown block for a canonical device group, e.g. + * ``` + * ### Acme InfusionPump (2.1.3) + * - **CPE**: cpe:2.3:... + * ``` + * Used by the chat/recommendations renderers and the VEX sorting agent so a + * device group is described consistently wherever it appears. + */ +export function deviceGroupToMarkdown(dg: DeviceGroupDisplay): string { + const version = displayName(dg.version); + const heading = `### ${deviceGroupLabel(dg)}${version ? ` (${version})` : ""}`; + const cpe = deviceGroupCpeList(dg); + return cpe ? `${heading}\n- **CPE**: ${cpe}` : heading; +} + +/** + * Inline identifier line for a loosely-typed device (CPE + manufacturer + model + * + version), e.g. "cpe=… | manufacturer=Acme | modelName=Pump | version=2.1". + * Shared by the notification matching agent for both the extracted device and + * its search candidates. Missing fields render as "?". + */ +export function deviceIdentityInline(fields: { + cpe?: string[] | string | null; + manufacturer?: string | null; + modelName?: string | null; + version?: string | null; +}): string { + const cpe = Array.isArray(fields.cpe) + ? fields.cpe.join(", ") || "(none)" + : (fields.cpe ?? "?"); + return `cpe=${cpe} | manufacturer=${fields.manufacturer ?? "?"} | modelName=${fields.modelName ?? "?"} | version=${fields.version ?? "?"}`; +} + type DeviceGroupMatchingDisplay = { vendor?: CanonicalRef; product?: CanonicalRef; diff --git a/src/test/server-only-stub.ts b/src/test/server-only-stub.ts new file mode 100644 index 00000000..2aac397c --- /dev/null +++ b/src/test/server-only-stub.ts @@ -0,0 +1,5 @@ +// No-op stand-in for the `server-only` package under Vitest, so server modules +// (which `import "server-only"` to guard against client bundling) can be +// imported in unit tests. In the real Next.js build the true package still +// enforces the server/client boundary. +export {}; diff --git a/vitest.config.mts b/vitest.config.mts index 11477d9f..0960e24e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -13,6 +13,8 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // Stub `server-only` so server modules can be imported in unit tests. + "server-only": path.resolve(__dirname, "./src/test/server-only-stub.ts"), }, }, }); From 5734d3432f6a5774f8371aff9e5afb56ff2d96c5 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Thu, 2 Jul 2026 13:49:11 -0400 Subject: [PATCH 04/12] reorganized agent code * reorganized agent code into multiple distinct files * cleaned up some prompt details --- .../agent/{ => vex}/__tests__/vex.test.ts | 4 +- .../inbox/agent/{vex.ts => vex/context.ts} | 298 +----------------- src/features/inbox/agent/vex/index.ts | 63 ++++ .../inbox/agent/vex/process_output.ts | 144 +++++++++ src/features/inbox/agent/vex/tools.ts | 117 +++++++ src/lib/prisma-client-extensions.ts | 5 +- 6 files changed, 334 insertions(+), 297 deletions(-) rename src/features/inbox/agent/{ => vex}/__tests__/vex.test.ts (97%) rename src/features/inbox/agent/{vex.ts => vex/context.ts} (56%) create mode 100644 src/features/inbox/agent/vex/index.ts create mode 100644 src/features/inbox/agent/vex/process_output.ts create mode 100644 src/features/inbox/agent/vex/tools.ts diff --git a/src/features/inbox/agent/__tests__/vex.test.ts b/src/features/inbox/agent/vex/__tests__/vex.test.ts similarity index 97% rename from src/features/inbox/agent/__tests__/vex.test.ts rename to src/features/inbox/agent/vex/__tests__/vex.test.ts index 55d70f0d..16ec33e1 100644 --- a/src/features/inbox/agent/__tests__/vex.test.ts +++ b/src/features/inbox/agent/vex/__tests__/vex.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { planVexWrites, type VexContext, type VexResult } from "../vex"; +import type { VexContext } from "../context"; +import { planVexWrites } from "../process_output"; +import type { VexResult } from "../tools"; // A notification linking one vuln whose matching resolves to a device group with // two assets, plus a second vuln/issue with no assets. diff --git a/src/features/inbox/agent/vex.ts b/src/features/inbox/agent/vex/context.ts similarity index 56% rename from src/features/inbox/agent/vex.ts rename to src/features/inbox/agent/vex/context.ts index f216447e..73711f45 100644 --- a/src/features/inbox/agent/vex.ts +++ b/src/features/inbox/agent/vex/context.ts @@ -1,19 +1,9 @@ -// VEX sorting agent: given a notification that has linked vulnerabilities, sort -// each baseline Issue (one per vulnerability × device-group-matching, created by -// vulnerabilityExtension) into "at risk" (AFFECTED), "possibly at risk" -// (UNDER_INVESTIGATION), or "unaffected" (NOT_AFFECTED + a VEX justification), -// using the hospital's notes and compensating controls as evidence. May also -// create asset-level override issues when a single asset differs from its group. +// VEX sorting agent context: gathers everything the agent needs for a +// notification (sources, vulns, remediations, device groups, notes, baseline +// issues) and renders it to a single markdown prompt. Deterministic (no LLM); +// separated so gather + render can be inspected independently of the model call. import "server-only"; -import { ChatAnthropic } from "@langchain/anthropic"; -import { tool } from "@langchain/core/tools"; -import { z } from "zod"; -import { - type ConfidenceLevel, - type IssueStatus, - NotAffectedJustification, -} from "@/generated/prisma"; import prisma from "@/lib/db"; import { deviceGroupWhereForMatching, @@ -26,94 +16,6 @@ import { deviceGroupToMarkdown, } from "@/lib/string-utils"; -const MODEL = "claude-sonnet-4-6"; - -// ─── Structured output schema ──────────────────────────────────────────────── -// -// The output is keyed by existing baseline Issue cuid (built dynamically per -// request in buildVexSchema) so the model must consider every specific issue; -// an omitted or empty value means "no change". `status` is a discriminated -// union so a justification is required exactly when status is NOT_AFFECTED. - -const statusSchema = z - .discriminatedUnion("status", [ - z.object({ status: z.literal("AFFECTED") }), - z.object({ status: z.literal("UNDER_INVESTIGATION") }), - z.object({ - status: z.literal("NOT_AFFECTED"), - justification: z - .nativeEnum(NotAffectedJustification) - .describe( - "VEX justification — required when the status is NOT_AFFECTED", - ), - }), - ]) - .describe( - "The determination. AFFECTED = at risk, UNDER_INVESTIGATION = possibly at risk (insufficient detail), NOT_AFFECTED = unaffected (must carry a justification).", - ); - -const assetOverrideSchema = z.object({ - id: z - .string() - .describe( - "Asset id — must be one of the asset ids listed under this issue. Only emit an override when this single asset differs from the rest of its device group.", - ), - status: statusSchema, - reasonWhy: z.string().describe("Concise justification for this asset."), -}); - -const issueValueSchema = z.object({ - status: statusSchema - .nullish() - .describe( - "The device-group-level determination. Omit to leave the group issue unchanged while still flagging an asset exception below.", - ), - reasonWhy: z - .string() - .nullish() - .describe("Concise reasoning for the group-level status, if you set one."), - confidence: z - .enum(["NeedsReview", "Matched"]) - .nullish() - .describe( - "Matched = strong evidence; NeedsReview = plausible but a human should verify. Never Confirmed (human-only).", - ), - assets: z - .array(assetOverrideSchema) - .nullish() - .describe( - "Asset-level overrides. Leave empty unless a specific asset differs from its device group (e.g. a note on that asset id).", - ), -}); - -/** Build the per-request tool schema: one optional property per baseline issue id. */ -function buildVexSchema(issueIds: string[]) { - return z.object( - Object.fromEntries(issueIds.map((id) => [id, issueValueSchema.optional()])), - ); -} - -// Loose types (independent of the dynamic zod schema) used by the deterministic -// planner so it can be unit-tested without constructing the schema. -type StatusValue = { - status: IssueStatus; - justification?: NotAffectedJustification | null; -}; -type AssetOverrideValue = { - id: string; - status: StatusValue; - reasonWhy: string; -}; -type IssueValue = { - status?: StatusValue; - reasonWhy?: string; - confidence?: "NeedsReview" | "Matched"; - assets?: AssetOverrideValue[] | null; -}; -export type VexResult = Record; - -// ─── Context ───────────────────────────────────────────────────────────────── - /** A baseline (device-group-matching-level) issue the agent may refine. */ export type VexIssueContext = { issueId: string; @@ -161,6 +63,7 @@ export async function gatherVexContext( }, }, issues: { where: { deviceGroupMatchingId: { not: null } } }, + // TODO/cassidy: should this include all issues? }, }, }, @@ -497,16 +400,13 @@ function renderVexPrompt(args: { // ─── System prompt ─────────────────────────────────────────────────────────── -const SYSTEM_PROMPT = `You are a VEX (Vulnerability Exploitability eXchange) analyst for a hospital cybersecurity platform. A security notification references one or more vulnerabilities, and the platform has already opened one baseline Issue per (vulnerability × affected device group). Your job is to sort each issue into the correct exploitability status using ONLY the evidence provided. +export const SYSTEM_PROMPT = `You are an issue triage analyst for a hospital cybersecurity platform. A security notification references one or more vulnerabilities, and the platform has already opened one baseline Issue per (vulnerability × affected device group). Your job is to sort each issue into the correct exploitability status using ONLY the evidence provided. Statuses: - AFFECTED ("at risk"): the vulnerability is exploitable on this device group as deployed. - UNDER_INVESTIGATION ("possibly at risk"): there is not enough detail about the device to decide. Use this when device details are insufficient. - NOT_AFFECTED ("unaffected"): evidence shows the vulnerability is not exploitable here. You MUST provide a VEX justification: - COMPONENT_NOT_PRESENT: the affected feature/component is not enabled or installed (e.g. a note says the affected feature is off). - - VULNERABLE_CODE_NOT_PRESENT / VULNERABLE_CODE_NOT_IN_EXECUTE_PATH: the vulnerable code isn't shipped or never runs. - - VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY: the exploit path can't be reached (e.g. log4shell on an infusion pump that never takes adversarial input). - - INLINE_MITIGATIONS_ALREADY_EXIST: a built-in mitigation neutralizes the vuln. - HOSPITAL_COMPENSATING_CONTROL: the hospital has a compensating control (e.g. an asset is not reachable over the network, which the exploit requires). - HOSPITAL_ACCEPTS_RISK: the hospital has explicitly accepted the risk. @@ -517,189 +417,3 @@ Rules: - Ground every decision in the provided sources, vulnerability descriptions, remediations, and notes. Never invent facts or numbers. - Set confidence to Matched only with strong evidence; otherwise NeedsReview. - Call the record_vex_determinations tool exactly once with your determinations. Omit issues you are not changing.`; - -// ─── Model call ────────────────────────────────────────────────────────────── - -export async function sortVulnerabilities( - context: VexContext, -): Promise { - const issueIds = context.issues.map((i) => i.issueId); - const schema = buildVexSchema(issueIds); - - const recordTool = tool(async () => "ok", { - name: "record_vex_determinations", - description: - "Record the VEX determination for each issue, keyed by issue id. Omit issues that are unchanged.", - schema, - }); - - // Extended thinking requires tool_choice "auto" (no forcing), so we bind the - // single tool and read the call args instead of using withStructuredOutput. - const model = new ChatAnthropic({ - model: MODEL, - maxTokens: 8000, - thinking: { type: "enabled", budget_tokens: 4000 }, - }).bindTools([recordTool]); - - const res = await model.invoke([ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: context.markdown }, - ]); - - const call = res.tool_calls?.find( - (c) => c.name === "record_vex_determinations", - ); - if (!call) return {}; - - const parsed = schema.safeParse(call.args); - return parsed.success ? (parsed.data as VexResult) : {}; -} - -// ─── Deterministic apply ───────────────────────────────────────────────────── - -export type VexApplySummary = { updated: number; created: number }; - -type IssueUpdateOp = { - issueId: string; - status: IssueStatus; - justification: NotAffectedJustification | null; - confidence: ConfidenceLevel; - notes: string; -}; -type AssetOverrideOp = { - assetId: string; - vulnerabilityId: string; - status: IssueStatus; - justification: NotAffectedJustification | null; - confidence: ConfidenceLevel; - notes: string; -}; - -/** Normalize a status value, dropping NOT_AFFECTED without a justification. */ -function normalizeStatus(status: StatusValue | undefined): { - status: IssueStatus; - justification: NotAffectedJustification | null; -} | null { - if (!status?.status) return null; - if (status.status === "NOT_AFFECTED") { - if (!status.justification) return null; - return { status: "NOT_AFFECTED", justification: status.justification }; - } - return { status: status.status, justification: null }; -} - -/** - * Turn an agent VexResult into deterministic write ops. Pure + guarded (no DB): - * skips ids that aren't baseline issues in context (hallucinations), skips - * NOT_AFFECTED without a justification, and skips asset overrides whose id isn't - * reachable through the issue. Unit-tested in isolation. - */ -export function planVexWrites( - context: VexContext, - result: VexResult, -): { issueUpdates: IssueUpdateOp[]; assetOverrides: AssetOverrideOp[] } { - const issuesById = new Map(context.issues.map((i) => [i.issueId, i])); - const issueUpdates: IssueUpdateOp[] = []; - const assetOverrides: AssetOverrideOp[] = []; - - for (const [issueId, value] of Object.entries(result)) { - const ctx = issuesById.get(issueId); - if (!ctx || !value) continue; // hallucinated id or no-change - - const confidence: ConfidenceLevel = - value.confidence === "Matched" ? "Matched" : "NeedsReview"; - - // Group-level update only when a status was supplied. A determination may - // carry only asset overrides, leaving the device-group issue untouched. - const norm = normalizeStatus(value.status); - if (norm) { - issueUpdates.push({ - issueId, - status: norm.status, - justification: norm.justification, - confidence, - notes: value.reasonWhy ?? "", - }); - } - - for (const override of value.assets ?? []) { - if (!ctx.assetIds.includes(override.id)) continue; // out-of-scope asset - const aNorm = normalizeStatus(override.status); - if (!aNorm) continue; - assetOverrides.push({ - assetId: override.id, - vulnerabilityId: ctx.vulnerabilityId, - status: aNorm.status, - justification: aNorm.justification, - confidence, - notes: override.reasonWhy ?? "", - }); - } - } - - return { issueUpdates, assetOverrides }; -} - -/** Apply planned VEX writes to the database in a single transaction. */ -export async function applyVexDeterminations( - context: VexContext, - result: VexResult, -): Promise { - const { issueUpdates, assetOverrides } = planVexWrites(context, result); - - await prisma.$transaction(async (tx) => { - for (const op of issueUpdates) { - await tx.issue.update({ - where: { id: op.issueId }, - data: { - status: op.status, - notAffectedJustification: op.justification, - statusConfidence: op.confidence, - statusNotes: op.notes, - }, - }); - } - - for (const op of assetOverrides) { - await tx.issue.upsert({ - where: { - assetId_vulnerabilityId: { - assetId: op.assetId, - vulnerabilityId: op.vulnerabilityId, - }, - }, - create: { - assetId: op.assetId, - vulnerabilityId: op.vulnerabilityId, - status: op.status, - notAffectedJustification: op.justification, - statusConfidence: op.confidence, - statusNotes: op.notes, - }, - update: { - status: op.status, - notAffectedJustification: op.justification, - statusConfidence: op.confidence, - statusNotes: op.notes, - }, - }); - } - }); - - return { updated: issueUpdates.length, created: assetOverrides.length }; -} - -/** - * End-to-end entry point used by the inbox pipeline: gather context, run the - * agent, and apply the result. Returns null when the notification has no linked - * vulnerabilities / issues to sort. - */ -export async function sortNotificationVulnerabilities( - notificationId: string, -): Promise<(VexApplySummary & { issues: number }) | null> { - const context = await gatherVexContext(notificationId); - if (!context) return null; - const result = await sortVulnerabilities(context); - const summary = await applyVexDeterminations(context, result); - return { ...summary, issues: context.issues.length }; -} diff --git a/src/features/inbox/agent/vex/index.ts b/src/features/inbox/agent/vex/index.ts new file mode 100644 index 00000000..564136b3 --- /dev/null +++ b/src/features/inbox/agent/vex/index.ts @@ -0,0 +1,63 @@ +// VEX sorting agent: given a notification that has linked vulnerabilities, sort +// each baseline Issue into AFFECTED, UNDER_INVESTIGATION, or NOT_AFFECTED +// May also create asset-level override issues when a single asset differs from +// its group. + +import "server-only"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { tool } from "@langchain/core/tools"; +import { gatherVexContext, SYSTEM_PROMPT, type VexContext } from "./context"; +import { applyVexDeterminations, type VexApplySummary } from "./process_output"; +import { buildVexSchema, type VexResult } from "./tools"; + +const MODEL = "claude-sonnet-4-6"; + +export async function sortVulnerabilities( + context: VexContext, +): Promise { + const issueIds = context.issues.map((i) => i.issueId); + const schema = buildVexSchema(issueIds); + + const recordTool = tool(async () => "ok", { + name: "record_vex_determinations", + description: + "Record the VEX determination for each issue, keyed by issue id. Omit issues that are unchanged.", + schema, + }); + + // Extended thinking requires tool_choice "auto" (no forcing), so we bind the + // single tool and read the call args instead of using withStructuredOutput. + const model = new ChatAnthropic({ + model: MODEL, + maxTokens: 8000, + thinking: { type: "enabled", budget_tokens: 4000 }, + }).bindTools([recordTool]); + + const res = await model.invoke([ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: context.markdown }, + ]); + + const call = res.tool_calls?.find( + (c) => c.name === "record_vex_determinations", + ); + if (!call) return {}; + + const parsed = schema.safeParse(call.args); + return parsed.success ? (parsed.data as VexResult) : {}; +} + +/** + * End-to-end entry point used by the inbox pipeline: gather context, run the + * agent, and apply the result. Returns null when the notification has no linked + * vulnerabilities / issues to sort. + */ +export async function sortNotificationVulnerabilities( + notificationId: string, +): Promise<(VexApplySummary & { issues: number }) | null> { + const context = await gatherVexContext(notificationId); + if (!context) return null; + const result = await sortVulnerabilities(context); + const summary = await applyVexDeterminations(context, result); + return { ...summary, issues: context.issues.length }; +} diff --git a/src/features/inbox/agent/vex/process_output.ts b/src/features/inbox/agent/vex/process_output.ts new file mode 100644 index 00000000..9b3b5cc4 --- /dev/null +++ b/src/features/inbox/agent/vex/process_output.ts @@ -0,0 +1,144 @@ +// VEX sorting agent deterministic apply: turns the agent's structured output +// into deterministic write ops and applies them to the database. + +import "server-only"; +import type { ConfidenceLevel, IssueStatus } from "@/generated/prisma"; +import prisma from "@/lib/db"; +import type { VexContext } from "./context"; +import type { + StatusValue, + VexNotAffectedJustification, + VexResult, +} from "./tools"; + +export type VexApplySummary = { updated: number; created: number }; + +type IssueUpdateOp = { + issueId: string; + status: IssueStatus; + justification: VexNotAffectedJustification | null; + confidence: ConfidenceLevel; + notes: string; +}; +type AssetOverrideOp = { + assetId: string; + vulnerabilityId: string; + status: IssueStatus; + justification: VexNotAffectedJustification | null; + confidence: ConfidenceLevel; + notes: string; +}; + +/** Normalize a status value, dropping NOT_AFFECTED without a justification. */ +function normalizeStatus(status: StatusValue | undefined): { + status: IssueStatus; + justification: VexNotAffectedJustification | null; +} | null { + if (!status?.status) return null; + if (status.status === "NOT_AFFECTED") { + if (!status.justification) return null; + return { status: "NOT_AFFECTED", justification: status.justification }; + } + return { status: status.status, justification: null }; +} + +/** + * Turn an agent VexResult into deterministic write ops. Pure + guarded (no DB): + * skips ids that aren't baseline issues in context (hallucinations), skips + * NOT_AFFECTED without a justification, and skips asset overrides whose id isn't + * reachable through the issue. Unit-tested in isolation. + */ +export function planVexWrites( + context: VexContext, + result: VexResult, +): { issueUpdates: IssueUpdateOp[]; assetOverrides: AssetOverrideOp[] } { + const issuesById = new Map(context.issues.map((i) => [i.issueId, i])); + const issueUpdates: IssueUpdateOp[] = []; + const assetOverrides: AssetOverrideOp[] = []; + + for (const [issueId, value] of Object.entries(result)) { + const ctx = issuesById.get(issueId); + if (!ctx || !value) continue; // hallucinated id or no-change + + const confidence: ConfidenceLevel = + value.confidence === "Matched" ? "Matched" : "NeedsReview"; + + // Group-level update only when a status was supplied. A determination may + // carry only asset overrides, leaving the device-group issue untouched. + const norm = normalizeStatus(value.status); + if (norm) { + issueUpdates.push({ + issueId, + status: norm.status, + justification: norm.justification, + confidence, + notes: value.reasonWhy ?? "", + }); + } + + for (const override of value.assets ?? []) { + if (!ctx.assetIds.includes(override.id)) continue; // out-of-scope asset + const aNorm = normalizeStatus(override.status); + if (!aNorm) continue; + assetOverrides.push({ + assetId: override.id, + vulnerabilityId: ctx.vulnerabilityId, + status: aNorm.status, + justification: aNorm.justification, + confidence, + notes: override.reasonWhy ?? "", + }); + } + } + + return { issueUpdates, assetOverrides }; +} + +/** Apply planned VEX writes to the database in a single transaction. */ +export async function applyVexDeterminations( + context: VexContext, + result: VexResult, +): Promise { + const { issueUpdates, assetOverrides } = planVexWrites(context, result); + + await prisma.$transaction(async (tx) => { + for (const op of issueUpdates) { + await tx.issue.update({ + where: { id: op.issueId }, + data: { + status: op.status, + notAffectedJustification: op.justification, + statusConfidence: op.confidence, + statusNotes: op.notes, + }, + }); + } + + for (const op of assetOverrides) { + await tx.issue.upsert({ + where: { + assetId_vulnerabilityId: { + assetId: op.assetId, + vulnerabilityId: op.vulnerabilityId, + }, + }, + create: { + assetId: op.assetId, + vulnerabilityId: op.vulnerabilityId, + status: op.status, + notAffectedJustification: op.justification, + statusConfidence: op.confidence, + statusNotes: op.notes, + }, + update: { + status: op.status, + notAffectedJustification: op.justification, + statusConfidence: op.confidence, + statusNotes: op.notes, + }, + }); + } + }); + + return { updated: issueUpdates.length, created: assetOverrides.length }; +} diff --git a/src/features/inbox/agent/vex/tools.ts b/src/features/inbox/agent/vex/tools.ts new file mode 100644 index 00000000..63086caa --- /dev/null +++ b/src/features/inbox/agent/vex/tools.ts @@ -0,0 +1,117 @@ +// VEX sorting agent tool/schema definitions: the structured output schema the +// agent's tool call is validated against, plus the loose types used by the +// deterministic planner so it can be unit-tested without constructing the +// dynamic zod schema. +// +// Example: +// { +// issue_id_i: { +// status: { +// status: "UNDER_INVESTIGATION", +// }, +// reasonWhy: "Unsure if code could be exploited here or not" +// }, +// issue_id_ii: { +// assets: { +// id: "asset_id_foo", +// status: { +// status: "NOT_AFFECTED", +// justification: "HOSPITAL_COMPENSATING_CONTROL" +// } +// reasonWhy: "This asset is on a subnet that never sees the light of day" +// } +// } +// } +// Means that issue_id_i is being modified to be under investigation, +// issue_id_ii stays the same, but asset_id_foo gets a new issue saying that +// this particular asset is not affected + +import "server-only"; +import { z } from "zod"; +import type { IssueStatus, NotAffectedJustification } from "@/generated/prisma"; + +/** Subset of NotAffectedJustification the agent is allowed to emit. */ +const VEX_JUSTIFICATIONS = [ + "COMPONENT_NOT_PRESENT", + "VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY", + "HOSPITAL_COMPENSATING_CONTROL", + "HOSPITAL_ACCEPTS_RISK", +] as const satisfies readonly NotAffectedJustification[]; +export type VexNotAffectedJustification = (typeof VEX_JUSTIFICATIONS)[number]; + +const statusSchema = z + .discriminatedUnion("status", [ + z.object({ status: z.literal("AFFECTED") }), + z.object({ status: z.literal("UNDER_INVESTIGATION") }), + z.object({ + status: z.literal("NOT_AFFECTED"), + justification: z + .enum(VEX_JUSTIFICATIONS) + .describe( + "VEX justification — required when the status is NOT_AFFECTED", + ), + }), + ]) + .describe( + "The determination. AFFECTED = at risk, UNDER_INVESTIGATION = possibly at risk (insufficient detail), NOT_AFFECTED = unaffected (must carry a justification).", + ); + +const assetOverrideSchema = z.object({ + id: z + .string() + .describe( + "Asset id — must be one of the asset ids listed under this issue. Only emit an override when this single asset differs from the rest of its device group.", + ), + status: statusSchema, + reasonWhy: z.string().describe("Concise justification for this asset."), +}); + +const issueValueSchema = z.object({ + status: statusSchema + .nullish() + .describe( + "The device-group-level determination. Omit to leave the group issue unchanged while still flagging an asset exception below.", + ), + reasonWhy: z + .string() + .nullish() + .describe("Concise reasoning for the group-level status, if you set one."), + confidence: z + .enum(["NeedsReview", "Matched"]) + .nullish() + .describe( + "Matched = strong evidence; NeedsReview = plausible but a human should verify.", + ), + assets: z + .array(assetOverrideSchema) + .nullish() + .describe( + "Asset-level overrides. Leave empty unless a specific asset differs from its device group (e.g. a note on that asset id).", + ), +}); + +/** Build the per-request tool schema: one optional property per baseline issue id. */ +export function buildVexSchema(issueIds: string[]) { + return z.object( + Object.fromEntries(issueIds.map((id) => [id, issueValueSchema.optional()])), + ); +} + +// Loose types (independent of the dynamic zod schema) used by the deterministic +// planner so it can be unit-tested without constructing the schema. +export type StatusValue = { + status: IssueStatus; + justification?: VexNotAffectedJustification | null; +}; +export type AssetOverrideValue = { + id: string; + status: StatusValue; + reasonWhy: string; +}; +export type IssueValue = { + status?: StatusValue; + reasonWhy?: string; + confidence?: "NeedsReview" | "Matched"; + assets?: AssetOverrideValue[] | null; +}; +export type VexResult = Record; diff --git a/src/lib/prisma-client-extensions.ts b/src/lib/prisma-client-extensions.ts index bd8567fa..422af606 100644 --- a/src/lib/prisma-client-extensions.ts +++ b/src/lib/prisma-client-extensions.ts @@ -82,10 +82,7 @@ export const vulnerabilityExtension = Prisma.defineExtension((client) => const vulnerabilityId = vulnerability.id as string; // Open one baseline issue per DeviceGroupMatching linked to this - // vulnerability, regardless of whether any assets exist yet. The VEX - // sorting agent later refines these (status/justification) and may add - // asset-level override issues. skipDuplicates keeps replays idempotent - // (issues are unique per [deviceGroupMatchingId, vulnerabilityId]). + // vulnerability, regardless of whether any assets exist yet. const matchings = await client.deviceGroupMatching.findMany({ where: { vulnerabilities: { some: { id: vulnerabilityId } } }, select: { id: true }, From 11a908e8e7198816eff4c88acd8bbb80a9daf4de Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 11:37:18 -0400 Subject: [PATCH 05/12] mostly comment changes --- prisma/schema.prisma | 3 +-- src/features/inbox/agent/vex/index.ts | 7 ++++--- src/features/inbox/agent/vex/process_output.ts | 6 +++--- src/features/inbox/agent/vex/tools.ts | 5 +---- .../vulnerabilities/components/prioritized-columns.tsx | 2 ++ 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 079223ff..c89ce6ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -703,8 +703,7 @@ model EntityFilter { note Note? @relation(fields: [noteId], references: [id], onDelete: Cascade) issueId String? issue Issue? @relation(fields: [issueId], references: [id], onDelete: Cascade) - // Exactly one of noteId/issueId must be set — enforce at the app layer (tRPC input - // validation); a raw-SQL check constraint could be added later via a manual migration. + // Exactly one of noteId/issueId must be set matches EntityFilterMatch[] lastResolvedAt DateTime? // null until a future memoization job runs diff --git a/src/features/inbox/agent/vex/index.ts b/src/features/inbox/agent/vex/index.ts index 564136b3..c0fcb38b 100644 --- a/src/features/inbox/agent/vex/index.ts +++ b/src/features/inbox/agent/vex/index.ts @@ -18,10 +18,11 @@ export async function sortVulnerabilities( const issueIds = context.issues.map((i) => i.issueId); const schema = buildVexSchema(issueIds); + const TOOL_NAME = "update_and_create_issues"; const recordTool = tool(async () => "ok", { - name: "record_vex_determinations", + name: TOOL_NAME, description: - "Record the VEX determination for each issue, keyed by issue id. Omit issues that are unchanged.", + "Record the issue status determination for each issue, keyed by id. Omit issues that are unchanged.", schema, }); @@ -39,7 +40,7 @@ export async function sortVulnerabilities( ]); const call = res.tool_calls?.find( - (c) => c.name === "record_vex_determinations", + (c) => c.name === TOOL_NAME, ); if (!call) return {}; diff --git a/src/features/inbox/agent/vex/process_output.ts b/src/features/inbox/agent/vex/process_output.ts index 9b3b5cc4..f586585f 100644 --- a/src/features/inbox/agent/vex/process_output.ts +++ b/src/features/inbox/agent/vex/process_output.ts @@ -1,5 +1,5 @@ -// VEX sorting agent deterministic apply: turns the agent's structured output -// into deterministic write ops and applies them to the database. +// turns the agent's structured output into deterministic write ops and +// applies them to the database. import "server-only"; import type { ConfidenceLevel, IssueStatus } from "@/generated/prisma"; @@ -46,7 +46,7 @@ function normalizeStatus(status: StatusValue | undefined): { * Turn an agent VexResult into deterministic write ops. Pure + guarded (no DB): * skips ids that aren't baseline issues in context (hallucinations), skips * NOT_AFFECTED without a justification, and skips asset overrides whose id isn't - * reachable through the issue. Unit-tested in isolation. + * reachable through the issue. */ export function planVexWrites( context: VexContext, diff --git a/src/features/inbox/agent/vex/tools.ts b/src/features/inbox/agent/vex/tools.ts index 63086caa..a132d240 100644 --- a/src/features/inbox/agent/vex/tools.ts +++ b/src/features/inbox/agent/vex/tools.ts @@ -1,7 +1,4 @@ -// VEX sorting agent tool/schema definitions: the structured output schema the -// agent's tool call is validated against, plus the loose types used by the -// deterministic planner so it can be unit-tested without constructing the -// dynamic zod schema. +// the structured output schema the agent's tool call is validated against, // // Example: // { diff --git a/src/features/vulnerabilities/components/prioritized-columns.tsx b/src/features/vulnerabilities/components/prioritized-columns.tsx index b2acb1d7..10497496 100644 --- a/src/features/vulnerabilities/components/prioritized-columns.tsx +++ b/src/features/vulnerabilities/components/prioritized-columns.tsx @@ -116,6 +116,8 @@ export const issueColumns: ColumnDef[] = [ row.original.asset ? getAssetRoleLabel(row.original.asset) : "Device Group", + // TODO: Consider a backend change where device group issues still get displayed + // in the frontend as one issue per asset in that device group }, { accessorKey: "status", From 5eeae10b550d5df70f2bf51c8e7d3ba8df6091b8 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 11:55:13 -0400 Subject: [PATCH 06/12] changed whitespace of schema.prisma --- prisma/schema.prisma | 486 +++++++++++++++++++++---------------------- 1 file changed, 243 insertions(+), 243 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2afec2c2..e9a4816e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,48 +10,48 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } model User { - id String @id - name String - email String? - emailVerified Boolean @default(false) - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - sessions Session[] - accounts Account[] - workflows Workflow[] - assets Asset[] - vulnerabilities Vulnerability[] - remediations Remediation[] - deviceArtifacts DeviceArtifact[] - artifacts Artifact[] - artifactWrappers ArtifactWrapper[] - advisories Advisory[] - - apikeys Apikey[] - apiKeyConnectors ApiKeyConnector[] - integration Integration[] - integrationUser Integration? @relation("integration_user") - webhooks Webhook[] - userTokens UserToken[] - threads ChatThread[] - memories Memory[] - notificationReads NotificationRead[] - - departmentId String? - department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) - workOrderTicketsCreated WorkOrderTicket[] @relation("WorkOrderTicketCreator") - workOrderTicketsAssigned WorkOrderTicket[] @relation("WorkOrderTicketAssignee") - ticketComments TicketComment[] - watchedTickets TicketWatch[] - ticketSeen TicketSeen[] - ticketActivities TicketActivity[] - notes Note[] + id String @id + name String + email String? + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + sessions Session[] + accounts Account[] + workflows Workflow[] + assets Asset[] + vulnerabilities Vulnerability[] + remediations Remediation[] + deviceArtifacts DeviceArtifact[] + artifacts Artifact[] + artifactWrappers ArtifactWrapper[] + advisories Advisory[] + + apikeys Apikey[] + apiKeyConnectors ApiKeyConnector[] + integration Integration[] + integrationUser Integration? @relation("integration_user") + webhooks Webhook[] + userTokens UserToken[] + threads ChatThread[] + memories Memory[] + notificationReads NotificationRead[] + + departmentId String? + department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + workOrderTicketsCreated WorkOrderTicket[] @relation("WorkOrderTicketCreator") + workOrderTicketsAssigned WorkOrderTicket[] @relation("WorkOrderTicketAssignee") + ticketComments TicketComment[] + watchedTickets TicketWatch[] + ticketSeen TicketSeen[] + ticketActivities TicketActivity[] + notes Note[] @@unique([email]) @@index([departmentId]) @@ -107,13 +107,13 @@ model Verification { } model UserToken { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - tokenHash String @unique + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique permissions String? // what can the token be used for? - expiresAt DateTime - createdAt DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) @@index([userId]) @@index([expiresAt]) @@ -220,9 +220,9 @@ enum VersScheme { // Canonical vendor registry. `nameMappings` are aliases that resolve to this row. model Vendor { id String @id @default(cuid()) - canonicalName String @unique - canonicalDisplayName String - hasCpe Boolean @default(false) + canonicalName String @unique + canonicalDisplayName String + hasCpe Boolean @default(false) nameMappings String[] @default([]) deviceGroups DeviceGroup[] matchings DeviceGroupMatching[] @@ -277,8 +277,8 @@ model DeviceGroup { udi String? helmSbomId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deviceGroupHistories DeviceGroupHistory[] assets Asset[] @@ -356,7 +356,7 @@ model Asset { issues Issue[] deviceGroupHistories DeviceGroupHistory[] externalMappings ExternalAssetMapping[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAssets") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAssets") notificationMappings NotificationAssetMapping[] createdAt DateTime @default(now()) @@ -371,7 +371,7 @@ model ExternalAssetMapping { id String @id @default(cuid()) itemId String integrationId String - externalId String // the id the asset has on say, Blueflow + externalId String // the id the asset has on say, Blueflow // Relationships item Asset @relation(fields: [itemId], references: [id], onDelete: Cascade) integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) @@ -406,14 +406,14 @@ model ExternalDeviceArtifactMapping { } model ExternalRemediationMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) itemId String integrationId String externalId String - item Remediation @relation(fields: [itemId], references: [id], onDelete: Cascade) - integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + item Remediation @relation(fields: [itemId], references: [id], onDelete: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt lastSynced DateTime? @@unique([itemId, integrationId], name: "external_remediation_mappings_item_integration_key") @@ -440,23 +440,23 @@ model ExternalVulnerabilityMapping { } model ArtifactWrapper { - id String @id @default(cuid()) + id String @id @default(cuid()) deviceArtifactId String? - deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: Cascade) - remediationId String? - remediation Remediation? @relation(fields: [remediationId], references: [id], onDelete: Cascade) + deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: Cascade) + remediationId String? + remediation Remediation? @relation(fields: [remediationId], references: [id], onDelete: Cascade) - latestArtifactId String? @unique - latestArtifact Artifact? @relation("LatestArtifact", fields: [latestArtifactId], references: [id], onDelete: SetNull) + latestArtifactId String? @unique + latestArtifact Artifact? @relation("LatestArtifact", fields: [latestArtifactId], references: [id], onDelete: SetNull) artifacts Artifact[] @relation("ArtifactWrapper") // back of fk relation userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([remediationId]) @@index([deviceArtifactId]) @@ -466,19 +466,19 @@ model ArtifactWrapper { } model Artifact { - id String @id @default(cuid()) + id String @id @default(cuid()) wrapperId String - wrapper ArtifactWrapper @relation("ArtifactWrapper", fields: [wrapperId], references: [id], onDelete: Cascade) + wrapper ArtifactWrapper @relation("ArtifactWrapper", fields: [wrapperId], references: [id], onDelete: Cascade) - name String? + name String? artifactType ArtifactType - downloadUrl String? @map("download-url") // Either provided by the performer or an artifact in S3 uploaded to us - hash String? // Verify incomplete or invalid uploads - size Int? // size in bytes + downloadUrl String? @map("download-url") // Either provided by the performer or an artifact in S3 uploaded to us + hash String? // Verify incomplete or invalid uploads + size Int? // size in bytes - prevVersionId String? @unique // null for v1 - prevVersion Artifact? @relation("VersionChain", fields: [prevVersionId], references: [id], onDelete: SetNull) - nextVersion Artifact? @relation("VersionChain") // opposite side of prevVersion + prevVersionId String? @unique // null for v1 + prevVersion Artifact? @relation("VersionChain", fields: [prevVersionId], references: [id], onDelete: SetNull) + nextVersion Artifact? @relation("VersionChain") // opposite side of prevVersion versionNumber Int // 1, 2, 3... @@ -487,8 +487,8 @@ model Artifact { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([wrapperId, versionNumber]) @@index([wrapperId]) @@ -550,49 +550,49 @@ enum AlohaStatus { } model Vulnerability { - id String @id @default(cuid()) + id String @id @default(cuid()) cveId String? - description String? @db.Text // description of vulnerability - narrative String? @db.Text // how a vulnerability could be exploited - impact String? @db.Text // clinical impact - severity Severity @default(Medium) + description String? @db.Text // description of vulnerability + narrative String? @db.Text // how a vulnerability could be exploited + impact String? @db.Text // clinical impact + severity Severity @default(Medium) cvssScore Float? cvssVector String? sarif Json - affectedComponents String[] @default([]) + affectedComponents String[] @default([]) deviceGroupMatchings DeviceGroupMatching[] @relation("VulnerabilityMatchings") - advisories Advisory[] @relation("AdvisoryVulnerabilities") - exploitUri String? @map("exploit-uri") - upstreamApi String? @map("upstream-api") + advisories Advisory[] @relation("AdvisoryVulnerabilities") + exploitUri String? @map("exploit-uri") + upstreamApi String? @map("upstream-api") // EPSS (Exploit Prediction Scoring System) fields - epss Float? // Probability score between 0.0 and 1.0 - updatedEpss DateTime? // When EPSS score was last updated - + epss Float? // Probability score between 0.0 and 1.0 + updatedEpss DateTime? // When EPSS score was last updated + // KEV (Known Exploited Vulnerabilities) fields - inKEV Boolean @default(false) // Whether in CISA KEV catalog - updatedInKev DateTime? // When KEV status was last checked/updated + inKEV Boolean @default(false) // Whether in CISA KEV catalog + updatedInKev DateTime? // When KEV status was last checked/updated - priority Priority @default(Unsorted) + priority Priority @default(Unsorted) alohaStatus AlohaStatus? - alohaLog Json @default("{}") + alohaLog Json @default("{}") - deviceArtifactId String? - deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: SetNull) + deviceArtifactId String? + deviceArtifact DeviceArtifact? @relation(fields: [deviceArtifactId], references: [id], onDelete: SetNull) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) remediations Remediation[] issues Issue[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketVulns") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketVulns") notificationMappings NotificationVulnerabilityMapping[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - externalMappings ExternalVulnerabilityMapping[] + externalMappings ExternalVulnerabilityMapping[] @@index([userId]) @@index([deviceArtifactId]) @@ -637,8 +637,8 @@ model Issue { statusConfidence ConfidenceLevel? statusNotes String? @db.Text - issueRemediations IssueRemediation[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues") + issueRemediations IssueRemediation[] + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues") filters EntityFilter[] createdAt DateTime @default(now()) @@ -725,26 +725,26 @@ model EntityFilterMatch { } model Remediation { - id String @id @default(cuid()) + id String @id @default(cuid()) artifacts ArtifactWrapper[] deviceGroupMatchings DeviceGroupMatching[] @relation("RemediationMatchings") - description String? @db.Text // description of remediation - narrative String? @db.Text // how a remediation can be applied - upstreamApi String? @map("upstream-api") + description String? @db.Text // description of remediation + narrative String? @db.Text // how a remediation can be applied + upstreamApi String? @map("upstream-api") vulnerabilityId String? vulnerability Vulnerability? @relation(fields: [vulnerabilityId], references: [id], onDelete: SetNull) issueRemediations IssueRemediation[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketRemediations") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketRemediations") notificationMappings NotificationRemediationMapping[] userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) alohaStatus AlohaStatus? - alohaLog Json @default("{}") + alohaLog Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -757,11 +757,11 @@ model Remediation { } model IssueRemediation { - issueId String - remediationId String + issueId String + remediationId String - issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) - remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) @@id([issueId, remediationId]) @@map("issue_remediation") @@ -791,37 +791,37 @@ model Apikey { permissions String? metadata String? - connector ApiKeyConnector? + connector ApiKeyConnector? @@map("apikey") } model Integration { - id String @id @default(cuid()) - name String - platform String? // The name of the integration platform, e.g "BlueFlow" - integrationUri String @map("integration-uri") - integrationType IntegrationType + id String @id @default(cuid()) + name String + platform String? // The name of the integration platform, e.g "BlueFlow" + integrationUri String @map("integration-uri") + integrationType IntegrationType // ^PARTNER: Helm, Blueflow. Follows VIPER standard // AI: uses AI to pull data from any platform // CSAF: parses CSAF security advisories (Vulnerability resource type only) - prompt String? @db.Text // optional, additional instructions for AI - authType AuthType - resourceType ResourceType - authentication Json? // TODO: this needs to be encrypted - syncEvery Int // e.g 300, sync every 300 seconds - syncStatus SyncStatus[] + prompt String? @db.Text // optional, additional instructions for AI + authType AuthType + resourceType ResourceType + authentication Json? // TODO: this needs to be encrypted + syncEvery Int // e.g 300, sync every 300 seconds + syncStatus SyncStatus[] lastSuccessfulSync DateTime? - apiKeyConnector ApiKeyConnector? + apiKeyConnector ApiKeyConnector? // The user who created the integration - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) // the user that this integration creates - integrationUserId String @unique - integrationUser User @relation(name: "integration_user", fields: [integrationUserId], references: [id], onDelete: Cascade) + integrationUserId String @unique + integrationUser User @relation(name: "integration_user", fields: [integrationUserId], references: [id], onDelete: Cascade) assetMappings ExternalAssetMapping[] deviceArtifactMappings ExternalDeviceArtifactMapping[] @@ -829,8 +829,8 @@ model Integration { vulnerabilityMappings ExternalVulnerabilityMapping[] advisoryMappings ExternalAdvisoryMapping[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@index([integrationUserId]) @@ -839,21 +839,21 @@ model Integration { // separate out to preserve between ApiKey rotations model ApiKeyConnector { - id String @id @default(cuid()) + id String @id @default(cuid()) - name String? - resourceType ResourceType? - lastRequest DateTime? + name String? + resourceType ResourceType? + lastRequest DateTime? - apiKeyId String? @unique - apiKey Apikey? @relation(fields: [apiKeyId], references: [id], onDelete: SetNull) - integrationId String? @unique - integration Integration? @relation(fields: [integrationId], references: [id], onDelete: SetNull) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + apiKeyId String? @unique + apiKey Apikey? @relation(fields: [apiKeyId], references: [id], onDelete: SetNull) + integrationId String? @unique + integration Integration? @relation(fields: [integrationId], references: [id], onDelete: SetNull) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@map("api_key_connector") @@ -871,15 +871,15 @@ model SyncStatus { integrationId String status SyncStatusEnum @default(Pending) errorMessage String? - syncedAt DateTime @default(now()) + syncedAt DateTime @default(now()) @@map("sync_status") } model Webhook { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) triggers TriggerEnum[] callbackUrl String @@ -887,8 +887,8 @@ model Webhook { authType AuthType authentication Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) } @@ -941,23 +941,23 @@ enum Tlp { } model Advisory { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String title String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - referencedVulnerabilities Vulnerability[] @relation("AdvisoryVulnerabilities") - deviceGroupMatchings DeviceGroupMatching[] @relation("AdvisoryMatchings") - csaf Json @default("{}") - upstreamUrl String? @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + referencedVulnerabilities Vulnerability[] @relation("AdvisoryVulnerabilities") + deviceGroupMatchings DeviceGroupMatching[] @relation("AdvisoryMatchings") + csaf Json @default("{}") + upstreamUrl String? @unique severity Severity - summary String? @db.Text - status IssueStatus @default(AFFECTED) + summary String? @db.Text + status IssueStatus @default(AFFECTED) tlp Tlp? publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt externalMappings ExternalAdvisoryMapping[] - workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAdvisories") + workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketAdvisories") @@index([userId]) @@map("advisory") @@ -981,13 +981,13 @@ model ExternalAdvisoryMapping { } model ChatThread { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - title String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - messages ChatMessage[] + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages ChatMessage[] @@index([userId]) } @@ -1004,17 +1004,17 @@ enum ChatMessageRole { } model ChatMessage { - id String @id @default(cuid()) - threadId String - thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) - status ChatMessageStatus? - role ChatMessageRole - content String? @db.Text - toolCalls Json? + id String @id @default(cuid()) + threadId String + thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + status ChatMessageStatus? + role ChatMessageRole + content String? @db.Text + toolCalls Json? toolStatus String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - checksum String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checksum String? @@index([threadId, createdAt]) } @@ -1050,12 +1050,12 @@ enum TicketCategory { } model Department { - id String @id @default(cuid()) - name String @unique - description String? @db.Text + id String @id @default(cuid()) + name String @unique + description String? @db.Text color String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt users User[] tickets WorkOrderTicket[] @relation("WorkOrderTicketDepartments") @@ -1075,14 +1075,14 @@ model CategoryColor { } model WorkOrderTicket { - id String @id @default(cuid()) - summary String + id String @id @default(cuid()) + summary String - status TicketStatus @default(TO_DO) - category TicketCategory @default(OTHER) + status TicketStatus @default(TO_DO) + category TicketCategory @default(OTHER) sourceLabel String? - sources NotificationSource[] + sources NotificationSource[] body String? @db.Text @@ -1090,33 +1090,33 @@ model WorkOrderTicket { departments Department[] @relation("WorkOrderTicketDepartments") - parentId String? - parent WorkOrderTicket? @relation("WorkOrderTicketChildren", fields: [parentId], references: [id], onDelete: Cascade) - children WorkOrderTicket[] @relation("WorkOrderTicketChildren") + parentId String? + parent WorkOrderTicket? @relation("WorkOrderTicketChildren", fields: [parentId], references: [id], onDelete: Cascade) + children WorkOrderTicket[] @relation("WorkOrderTicketChildren") - creatorId String - creator User @relation("WorkOrderTicketCreator", fields: [creatorId], references: [id], onDelete: Cascade) - assigneeId String? - assignee User? @relation("WorkOrderTicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + creatorId String + creator User @relation("WorkOrderTicketCreator", fields: [creatorId], references: [id], onDelete: Cascade) + assigneeId String? + assignee User? @relation("WorkOrderTicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) scheduledAt DateTime? - issues Issue[] @relation("WorkOrderTicketIssues") - vulnerabilities Vulnerability[] @relation("WorkOrderTicketVulns") - remediations Remediation[] @relation("WorkOrderTicketRemediations") - advisories Advisory[] @relation("WorkOrderTicketAdvisories") - assets Asset[] @relation("WorkOrderTicketAssets") + issues Issue[] @relation("WorkOrderTicketIssues") + vulnerabilities Vulnerability[] @relation("WorkOrderTicketVulns") + remediations Remediation[] @relation("WorkOrderTicketRemediations") + advisories Advisory[] @relation("WorkOrderTicketAdvisories") + assets Asset[] @relation("WorkOrderTicketAssets") deviceGroups NotificationDeviceGroupMapping[] - comments TicketComment[] - descriptions TicketDescription[] - watchers TicketWatch[] - seenBy TicketSeen[] - activities TicketActivity[] - lastCommentAt DateTime? + comments TicketComment[] + descriptions TicketDescription[] + watchers TicketWatch[] + seenBy TicketSeen[] + activities TicketActivity[] + lastCommentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status]) @@index([assigneeId]) @@ -1168,14 +1168,14 @@ enum TicketActivityType { } model TicketActivity { - id String @id @default(cuid()) + id String @id @default(cuid()) ticketId String - ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) type TicketActivityType - data Json // shape varies by `type`; structured server-side - createdAt DateTime @default(now()) + data Json // shape varies by `type`; structured server-side + createdAt DateTime @default(now()) @@index([ticketId, createdAt]) @@index([userId]) @@ -1183,14 +1183,14 @@ model TicketActivity { } model TicketComment { - id String @id @default(cuid()) + id String @id @default(cuid()) ticketId String ticket WorkOrderTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) authorId String - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - body String @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + body String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([ticketId, createdAt]) @@index([authorId]) @@ -1248,9 +1248,9 @@ model Notification { summary String? @db.Text details Json? // unindexed, NotificationType-specific fields - priority Priority @default(Unsorted) - priorityReasonWhy String? @db.Text - hospitalImpact String? @db.Text + priority Priority @default(Unsorted) + priorityReasonWhy String? @db.Text + hospitalImpact String? @db.Text sources NotificationSource[] reads NotificationRead[] @@ -1267,24 +1267,24 @@ model Notification { } model NotificationSource { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String? - notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) workOrderTicketId String? workOrderTicket WorkOrderTicket? @relation(fields: [workOrderTicketId], references: [id], onDelete: Cascade) sourceType NotificationSourceType @default(Source) - reasonWhy String? @db.Text // why was this email linked to this notification? + reasonWhy String? @db.Text // why was this email linked to this notification? channel NotificationChannel externalId String? // for an email, this is the resend email id referenceUrl String? raw Json - markdown String? @db.Text + markdown String? @db.Text attachments NotificationAttachment[] - receivedAt DateTime @default(now()) + receivedAt DateTime @default(now()) @@unique([channel, externalId]) @@index([notificationId]) @@ -1293,9 +1293,9 @@ model NotificationSource { } model NotificationAttachment { - id String @id @default(cuid()) - sourceId String - source NotificationSource @relation(fields: [sourceId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + sourceId String + source NotificationSource @relation(fields: [sourceId], references: [id], onDelete: Cascade) filename String? contentType String? @@ -1323,23 +1323,23 @@ model NotificationRead { } model NotificationAssetMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) assetId String - asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? - reasonWhy String? + reasonWhy String? @@unique([notificationId, assetId]) @@map("notification_asset_mapping") } model NotificationDeviceGroupMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) // A mapping belongs to a Notification OR a WorkOrderTicket (exactly one). - notificationId String? - notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notificationId String? + notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) workOrderTicketId String? workOrderTicket WorkOrderTicket? @relation(fields: [workOrderTicketId], references: [id], onDelete: Cascade) deviceGroupMatchingId String @@ -1355,26 +1355,26 @@ model NotificationDeviceGroupMapping { } model NotificationVulnerabilityMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) vulnerabilityId String - vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade) + vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? - reasonWhy String? + reasonWhy String? @@unique([notificationId, vulnerabilityId]) @@map("notification_vulnerability_mapping") } model NotificationRemediationMapping { - id String @id @default(cuid()) + id String @id @default(cuid()) notificationId String - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) remediationId String - remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) + remediation Remediation @relation(fields: [remediationId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? - reasonWhy String? + reasonWhy String? @@unique([notificationId, remediationId]) @@map("notification_remediation_mapping") From 7b93d04438ae88a261edb01cd1cefb7f49da2150 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 12:07:38 -0400 Subject: [PATCH 07/12] fetch device groups from deviceGroupsMatchings --- src/features/inbox/agent/vex/context.ts | 39 ++++++++++--------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/features/inbox/agent/vex/context.ts b/src/features/inbox/agent/vex/context.ts index 73711f45..afe1c51f 100644 --- a/src/features/inbox/agent/vex/context.ts +++ b/src/features/inbox/agent/vex/context.ts @@ -51,17 +51,21 @@ export async function gatherVexContext( where: { id: notificationId }, include: { sources: { select: { markdown: true, channel: true } }, + deviceGroupsMatchings: { + include: { + deviceGroupMatching: { + include: { + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { select: { canonicalDisplayName: true } }, + }, + }, + }, + }, vulnerabilities: { include: { vulnerability: { include: { - deviceGroupMatchings: { - include: { - vendor: { select: { canonicalDisplayName: true } }, - product: { select: { canonicalDisplayName: true } }, - version: { select: { canonicalDisplayName: true } }, - }, - }, issues: { where: { deviceGroupMatchingId: { not: null } } }, // TODO/cassidy: should this include all issues? }, @@ -70,17 +74,7 @@ export async function gatherVexContext( }, remediations: { include: { - remediation: { - include: { - deviceGroupMatchings: { - include: { - vendor: { select: { canonicalDisplayName: true } }, - product: { select: { canonicalDisplayName: true } }, - version: { select: { canonicalDisplayName: true } }, - }, - }, - }, - }, + remediation: true, }, }, }, @@ -95,13 +89,10 @@ export async function gatherVexContext( const remediations = notification.remediations.map((m) => m.remediation); - // All matchings referenced by the linked vulns (+ remediations, for notes). + // All matchings linked to this notification directly const matchingsById = new Map(); - for (const v of vulnerabilities) { - for (const dgm of v.deviceGroupMatchings) matchingsById.set(dgm.id, dgm); - } - for (const r of remediations) { - for (const dgm of r.deviceGroupMatchings) matchingsById.set(dgm.id, dgm); + for (const m of notification.deviceGroupsMatchings) { + matchingsById.set(m.deviceGroupMatching.id, m.deviceGroupMatching); } const matchings = [...matchingsById.values()]; From 43c0b4553c4354378cb39b55f7da66aa3d882756 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 13:24:13 -0400 Subject: [PATCH 08/12] device group matching change again --- src/features/inbox/agent/vex/context.ts | 27 ++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/features/inbox/agent/vex/context.ts b/src/features/inbox/agent/vex/context.ts index afe1c51f..5469f014 100644 --- a/src/features/inbox/agent/vex/context.ts +++ b/src/features/inbox/agent/vex/context.ts @@ -51,21 +51,17 @@ export async function gatherVexContext( where: { id: notificationId }, include: { sources: { select: { markdown: true, channel: true } }, - deviceGroupsMatchings: { - include: { - deviceGroupMatching: { - include: { - vendor: { select: { canonicalDisplayName: true } }, - product: { select: { canonicalDisplayName: true } }, - version: { select: { canonicalDisplayName: true } }, - }, - }, - }, - }, vulnerabilities: { include: { vulnerability: { include: { + deviceGroupMatchings: { + include: { + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { select: { canonicalDisplayName: true } }, + }, + }, issues: { where: { deviceGroupMatchingId: { not: null } } }, // TODO/cassidy: should this include all issues? }, @@ -89,10 +85,13 @@ export async function gatherVexContext( const remediations = notification.remediations.map((m) => m.remediation); - // All matchings linked to this notification directly + // All matchings referenced by the linked vulnerabilities. + // NOTE: An Issue relates a device group matching to a vuln. We only grab + // DGM's related to vulns (not DGM's related to the notification) since this + // agent only cares about Issue's, not the entire notification const matchingsById = new Map(); - for (const m of notification.deviceGroupsMatchings) { - matchingsById.set(m.deviceGroupMatching.id, m.deviceGroupMatching); + for (const v of vulnerabilities) { + for (const dgm of v.deviceGroupMatchings) matchingsById.set(dgm.id, dgm); } const matchings = [...matchingsById.values()]; From e5d3fcbb81ad0cd080de83f9a1f9264ced03665b Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 18:17:14 -0400 Subject: [PATCH 09/12] new seed script; get notes stub --- scripts/seed-notifications.ts | 224 ++++++++++++++++++ src/features/inbox/agent/vex/context.ts | 28 +-- .../notes/server/get-relevant-notes.ts | 119 ++++++++++ 3 files changed, 350 insertions(+), 21 deletions(-) create mode 100644 src/features/notes/server/get-relevant-notes.ts diff --git a/scripts/seed-notifications.ts b/scripts/seed-notifications.ts index e00e49a3..ae8cc4e6 100644 --- a/scripts/seed-notifications.ts +++ b/scripts/seed-notifications.ts @@ -4,7 +4,10 @@ import { NotificationChannel, NotificationType, Priority, + ScopeTargetModel, + Severity, Tlp, + VersionStatus, } from "@/generated/prisma"; import prisma from "../src/lib/db"; @@ -34,6 +37,15 @@ function upsertProduct(name: string) { }); } +function upsertVersion(name: string) { + const canonicalName = name.trim().toLowerCase(); + return prisma.version.upsert({ + where: { canonicalName }, + update: {}, + create: { canonicalName, canonicalDisplayName: name, hasCpe: true }, + }); +} + // --------------------------------------------------------------------------- // Device groups + assets // --------------------------------------------------------------------------- @@ -352,6 +364,217 @@ const NOTIFICATIONS: NotificationSeed[] = [ }, ]; +// --------------------------------------------------------------------------- +// Siemens syngo.plaza VEX scenario (SSA-016040 / CVE-2024-52334) +// --------------------------------------------------------------------------- +// +// Exercises the vex agent's asset-level override path: the device group as a +// whole is affected by the CVE, but a Note on one specific asset gives the +// agent grounds to mark that asset NOT_AFFECTED while the sibling asset (and +// the group-level issue) stay AFFECTED. + +const SYNGO_PLAZA = { + vendor: "Siemens Healthineers", + product: "syngo.plaza", + version: "VB30E", +}; + +const SYNGO_PLAZA_ASSETS = [ + { + ip: "10.50.0.11", + hostname: "pacs-syngo-01", + serialNumber: "SYNGO-PLZ-VB30E-001", + role: "PACS Workstation", + networkSegment: "RADIOLOGY-PACS", + }, + { + ip: "10.50.0.12", + hostname: "pacs-syngo-02", + serialNumber: "SYNGO-PLZ-VB30E-002", + role: "PACS Workstation", + networkSegment: "RADIOLOGY-PACS", + }, +]; + +async function seedSyngoPlazaVexScenario(userId: string) { + console.log("\n🌱 Seeding Siemens syngo.plaza VEX scenario..."); + + const vendor = await upsertVendor(SYNGO_PLAZA.vendor); + const product = await upsertProduct(SYNGO_PLAZA.product); + const version = await upsertVersion(SYNGO_PLAZA.version); + + const groupIdentity = { + vendorId: vendor.id, + productId: product.id, + versionId: version.id, + versionStatus: VersionStatus.KNOWN, + }; + const deviceGroup = + (await prisma.deviceGroup.findFirst({ where: groupIdentity })) ?? + (await prisma.deviceGroup.create({ data: groupIdentity })); + + const assets = await Promise.all( + SYNGO_PLAZA_ASSETS.map(async (asset) => { + const existing = await prisma.asset.findFirst({ + where: { serialNumber: asset.serialNumber }, + }); + if (existing) return existing; + return prisma.asset.create({ + data: { + ...asset, + upstreamApi: "https://example.com/placeholder", + status: "Active" as AssetStatus, + deviceGroupId: deviceGroup.id, + userId, + }, + }); + }), + ); + + // TODO: matching fn for something like "vers:generic/ g.assets.map((a) => a.id))), ]; - const notes = await prisma.note.findMany({ - where: { - OR: [ - { status: "PERSISTENT" }, - { - targetModel: "VULNERABILITY", - instanceId: { in: vulnerabilities.map((v) => v.id) }, - }, - { - targetModel: "REMEDIATION", - instanceId: { in: remediations.map((r) => r.id) }, - }, - { targetModel: "DEVICE_GROUP", instanceId: { in: groupIds } }, - { - targetModel: "DEVICE_GROUP_MATCHING", - instanceId: { in: matchings.map((m) => m.id) }, - }, - { targetModel: "ASSET", instanceId: { in: allAssetIds } }, - ], - }, - select: { text: true, status: true, targetModel: true, instanceId: true }, + const notes = await getRelevantNotes({ + vulnerabilityIds: vulnerabilities.map((v) => v.id), + remediationIds: remediations.map((r) => r.id), + deviceGroupIds: groupIds, + deviceGroupMatchingIds: matchings.map((m) => m.id), + assetIds: allAssetIds, }); // Label lookups for rendering note targets. diff --git a/src/features/notes/server/get-relevant-notes.ts b/src/features/notes/server/get-relevant-notes.ts new file mode 100644 index 00000000..518f33cd --- /dev/null +++ b/src/features/notes/server/get-relevant-notes.ts @@ -0,0 +1,119 @@ +// Central helper for resolving which Notes are relevant to a given scope. +// +// A Note is relevant either because its status is PERSISTENT (always applies) +// or because it directly references an in-scope entity via targetModel + +// instanceId. This mirrors the inline query the VEX context used to run, and is +// the single place note relevance should be computed. +// +// This is intentionally a MINIMAL implementation. Two future relevance sources +// are stubbed out with TODOs below and not yet wired up: +// 1. EntityFilterMatch — notes attached via an EntityFilter, matched to +// concrete entities by a future Inngest job that memoizes matches. +// 2. Device-group-matching resolution — a note on a DeviceGroupMatching whose +// vendor/product/versionRange matches an asset's device group (e.g. a +// "vers:all/*" range matching every version of a product). + +import "server-only"; +import type { NoteStatus, ScopeTargetModel } from "@/generated/prisma"; +import prisma from "@/lib/db"; + +/** The projection of a Note returned by the relevance helpers. */ +export type RelevantNote = { + text: string; + status: NoteStatus; + targetModel: ScopeTargetModel | null; + instanceId: string | null; +}; + +/** + * In-scope entity ids, grouped by the model they belong to. Any omitted or + * empty list simply contributes no direct references. + */ +export type NoteScope = { + vulnerabilityIds?: string[]; + remediationIds?: string[]; + deviceGroupIds?: string[]; + deviceGroupMatchingIds?: string[]; + assetIds?: string[]; +}; + +const NOTE_SELECT = { + text: true, + status: true, + targetModel: true, + instanceId: true, +} as const; + +/** + * All notes relevant to the given scope: every PERSISTENT note plus any note + * whose targetModel/instanceId points at one of the supplied ids. + */ +export async function getRelevantNotes( + scope: NoteScope, +): Promise { + const direct: Array<{ targetModel: ScopeTargetModel; ids: string[] }> = [ + { targetModel: "VULNERABILITY", ids: scope.vulnerabilityIds ?? [] }, + { targetModel: "REMEDIATION", ids: scope.remediationIds ?? [] }, + { targetModel: "DEVICE_GROUP", ids: scope.deviceGroupIds ?? [] }, + { + targetModel: "DEVICE_GROUP_MATCHING", + ids: scope.deviceGroupMatchingIds ?? [], + }, + { targetModel: "ASSET", ids: scope.assetIds ?? [] }, + ]; + + const or: Array<{ + status?: NoteStatus; + targetModel?: ScopeTargetModel; + instanceId?: { in: string[] }; + }> = [{ status: "PERSISTENT" }]; + + for (const { targetModel, ids } of direct) { + if (ids.length > 0) or.push({ targetModel, instanceId: { in: ids } }); + } + + // TODO(EntityFilterMatch): once a background job populates EntityFilterMatch + // from EntityFilter, also include notes reachable via + // EntityFilterMatch.targetId ∈ scope ids → EntityFilter.noteId (scoped by + // EntityFilter.targetModel). Not implemented yet, so filter-based notes are + // not returned today. + + return prisma.note.findMany({ where: { OR: or }, select: NOTE_SELECT }); +} + +/** + * All notes relevant to a single asset: notes attached directly to the asset, + * notes attached to its device group, and all PERSISTENT notes. + */ +export async function getNotesForAsset( + assetId: string, +): Promise { + const asset = await prisma.asset.findUnique({ + where: { id: assetId }, + select: { deviceGroupId: true }, + }); + + // TODO(device-group-matching): also include notes on DeviceGroupMatchings + // that apply to this asset's device group. Resolve candidates with + // matchingWhereForDeviceGroup + matchingAppliesToDeviceGroup from + // @/lib/device-matching, then pass their ids as deviceGroupMatchingIds. + + return getRelevantNotes({ + assetIds: [assetId], + deviceGroupIds: asset ? [asset.deviceGroupId] : [], + }); +} + +/** + * All notes relevant to a single device group: notes attached directly to the + * group, plus all PERSISTENT notes. + */ +export async function getNotesForDeviceGroup( + deviceGroupId: string, +): Promise { + // TODO(device-group-matching): also include notes on DeviceGroupMatchings + // that apply to this device group (see matchingWhereForDeviceGroup + + // matchingAppliesToDeviceGroup in @/lib/device-matching). + + return getRelevantNotes({ deviceGroupIds: [deviceGroupId] }); +} From 75edfbfc5f5efa3d7a894dec0b8f484c302986f8 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 18:48:40 -0400 Subject: [PATCH 10/12] notes no longer reference dg directly --- .../migration.sql | 2 +- prisma/schema.prisma | 1 - src/features/inbox/agent/vex/context.ts | 4 - .../notes/server/get-relevant-notes.ts | 157 +++++++++--------- 4 files changed, 79 insertions(+), 85 deletions(-) diff --git a/prisma/migrations/20260701212209_issue_and_notes/migration.sql b/prisma/migrations/20260701212209_issue_and_notes/migration.sql index 5d247430..108a0bdf 100644 --- a/prisma/migrations/20260701212209_issue_and_notes/migration.sql +++ b/prisma/migrations/20260701212209_issue_and_notes/migration.sql @@ -16,7 +16,7 @@ CREATE TYPE "NotAffectedJustification" AS ENUM ('COMPONENT_NOT_PRESENT', 'VULNER CREATE TYPE "NoteStatus" AS ENUM ('PERSISTENT', 'SCOPED'); -- CreateEnum -CREATE TYPE "ScopeTargetModel" AS ENUM ('DEVICE_GROUP', 'DEVICE_GROUP_MATCHING', 'ASSET', 'VULNERABILITY', 'REMEDIATION'); +CREATE TYPE "ScopeTargetModel" AS ENUM ('DEVICE_GROUP_MATCHING', 'ASSET', 'VULNERABILITY', 'REMEDIATION'); -- AlterTable -- Remap "advisory"."status" to the new IssueStatus values without dropping diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9a4816e..33381e89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -658,7 +658,6 @@ enum NoteStatus { } enum ScopeTargetModel { - DEVICE_GROUP DEVICE_GROUP_MATCHING ASSET VULNERABILITY diff --git a/src/features/inbox/agent/vex/context.ts b/src/features/inbox/agent/vex/context.ts index 1782712f..13d80d80 100644 --- a/src/features/inbox/agent/vex/context.ts +++ b/src/features/inbox/agent/vex/context.ts @@ -165,14 +165,12 @@ export async function gatherVexContext( // Notes: direct (targetModel + instanceId points at an in-scope entity) plus // all PERSISTENT notes (which always apply to the hospital). - const groupIds = candidateGroups.map((g) => g.id); const allAssetIds = [ ...new Set(candidateGroups.flatMap((g) => g.assets.map((a) => a.id))), ]; const notes = await getRelevantNotes({ vulnerabilityIds: vulnerabilities.map((v) => v.id), remediationIds: remediations.map((r) => r.id), - deviceGroupIds: groupIds, deviceGroupMatchingIds: matchings.map((m) => m.id), assetIds: allAssetIds, }); @@ -224,8 +222,6 @@ function renderNoteTarget( switch (note.targetModel) { case "ASSET": return `Asset ${labels.assetLabel.get(id) ?? id} (id: ${id})`; - case "DEVICE_GROUP": - return `Device group ${labels.groupLabel.get(id) ?? id}`; case "DEVICE_GROUP_MATCHING": return `Matching ${labels.matchingLabel.get(id) ?? id}`; case "VULNERABILITY": diff --git a/src/features/notes/server/get-relevant-notes.ts b/src/features/notes/server/get-relevant-notes.ts index 518f33cd..9f49f34d 100644 --- a/src/features/notes/server/get-relevant-notes.ts +++ b/src/features/notes/server/get-relevant-notes.ts @@ -1,43 +1,23 @@ -// Central helper for resolving which Notes are relevant to a given scope. +// Central helper for matching notes to VIPER models. // -// A Note is relevant either because its status is PERSISTENT (always applies) -// or because it directly references an in-scope entity via targetModel + -// instanceId. This mirrors the inline query the VEX context used to run, and is -// the single place note relevance should be computed. -// -// This is intentionally a MINIMAL implementation. Two future relevance sources -// are stubbed out with TODOs below and not yet wired up: -// 1. EntityFilterMatch — notes attached via an EntityFilter, matched to -// concrete entities by a future Inngest job that memoizes matches. -// 2. Device-group-matching resolution — a note on a DeviceGroupMatching whose -// vendor/product/versionRange matches an asset's device group (e.g. a -// "vers:all/*" range matching every version of a product). +// A Note is relevant to an object either because it directly references that +// object via targetModel + instanceId, or via EntityFilterMatch import "server-only"; import type { NoteStatus, ScopeTargetModel } from "@/generated/prisma"; import prisma from "@/lib/db"; -/** The projection of a Note returned by the relevance helpers. */ +/** The projection of a Note returned by the note helpers. */ export type RelevantNote = { + id: string; text: string; status: NoteStatus; targetModel: ScopeTargetModel | null; instanceId: string | null; }; -/** - * In-scope entity ids, grouped by the model they belong to. Any omitted or - * empty list simply contributes no direct references. - */ -export type NoteScope = { - vulnerabilityIds?: string[]; - remediationIds?: string[]; - deviceGroupIds?: string[]; - deviceGroupMatchingIds?: string[]; - assetIds?: string[]; -}; - const NOTE_SELECT = { + id: true, text: true, status: true, targetModel: true, @@ -45,75 +25,94 @@ const NOTE_SELECT = { } as const; /** - * All notes relevant to the given scope: every PERSISTENT note plus any note - * whose targetModel/instanceId points at one of the supplied ids. + * Notes matching one or more objects of a single targetModel, by id. Does not + * include PERSISTENT notes (see getRelevantNotes for that). */ -export async function getRelevantNotes( - scope: NoteScope, +export async function getNotesForInstance( + targetModel: ScopeTargetModel, + ids: string[], ): Promise { - const direct: Array<{ targetModel: ScopeTargetModel; ids: string[] }> = [ - { targetModel: "VULNERABILITY", ids: scope.vulnerabilityIds ?? [] }, - { targetModel: "REMEDIATION", ids: scope.remediationIds ?? [] }, - { targetModel: "DEVICE_GROUP", ids: scope.deviceGroupIds ?? [] }, - { - targetModel: "DEVICE_GROUP_MATCHING", - ids: scope.deviceGroupMatchingIds ?? [], - }, - { targetModel: "ASSET", ids: scope.assetIds ?? [] }, - ]; + if (ids.length === 0) return []; - const or: Array<{ - status?: NoteStatus; - targetModel?: ScopeTargetModel; - instanceId?: { in: string[] }; - }> = [{ status: "PERSISTENT" }]; - - for (const { targetModel, ids } of direct) { - if (ids.length > 0) or.push({ targetModel, instanceId: { in: ids } }); - } + // TODO(EntityFilterMatch): VW-358 -- also include notes that match + // via EntityFilterMatch - // TODO(EntityFilterMatch): once a background job populates EntityFilterMatch - // from EntityFilter, also include notes reachable via - // EntityFilterMatch.targetId ∈ scope ids → EntityFilter.noteId (scoped by - // EntityFilter.targetModel). Not implemented yet, so filter-based notes are - // not returned today. + return prisma.note.findMany({ + where: { targetModel, instanceId: { in: ids } }, + select: NOTE_SELECT, + }); +} - return prisma.note.findMany({ where: { OR: or }, select: NOTE_SELECT }); +/** + * Notes matching a single device group. Stubbed today: notes cannot attach to a + * device group directly, only to device group matchings. + */ +export async function getNotesForDeviceGroup( + _deviceGroupId: string, +): Promise { + // TODO(device-group-matching): resolve the DeviceGroupMatchings that match + // this device group (reuse matchingWhereForDeviceGroup + + // matchingAppliesToDeviceGroup from @/lib/device-matching), then + // `return getNotesForInstance("DEVICE_GROUP_MATCHING", matchingIds)`. + return []; } /** - * All notes relevant to a single asset: notes attached directly to the asset, - * notes attached to its device group, and all PERSISTENT notes. + * Notes matching a single asset. Today: notes attached directly to the asset. */ export async function getNotesForAsset( assetId: string, ): Promise { - const asset = await prisma.asset.findUnique({ - where: { id: assetId }, - select: { deviceGroupId: true }, - }); - - // TODO(device-group-matching): also include notes on DeviceGroupMatchings - // that apply to this asset's device group. Resolve candidates with - // matchingWhereForDeviceGroup + matchingAppliesToDeviceGroup from - // @/lib/device-matching, then pass their ids as deviceGroupMatchingIds. - - return getRelevantNotes({ - assetIds: [assetId], - deviceGroupIds: asset ? [asset.deviceGroupId] : [], - }); + // TODO(device-group-matching): also include notes on the DeviceGroupMatching(s) + // this asset's device group belongs to — look up asset.deviceGroupId and + // delegate to getNotesForDeviceGroup. + return getNotesForInstance("ASSET", [assetId]); } /** - * All notes relevant to a single device group: notes attached directly to the - * group, plus all PERSISTENT notes. + * In-scope entity ids, grouped by the model they belong to. Any omitted or + * empty list simply contributes no references. */ -export async function getNotesForDeviceGroup( - deviceGroupId: string, +export type NoteScope = { + vulnerabilityIds?: string[]; + remediationIds?: string[]; + deviceGroupMatchingIds?: string[]; + assetIds?: string[]; +}; + +/** + * All notes relevant to the given scope: every PERSISTENT note plus any note + * that matches one of the supplied entities. Deduped by note id. + */ +export async function getRelevantNotes( + scope: NoteScope, ): Promise { - // TODO(device-group-matching): also include notes on DeviceGroupMatchings - // that apply to this device group (see matchingWhereForDeviceGroup + - // matchingAppliesToDeviceGroup in @/lib/device-matching). + const [persistent, vulnerabilities, remediations, matchings, assets] = + await Promise.all([ + prisma.note.findMany({ + where: { status: "PERSISTENT" }, + select: NOTE_SELECT, + }), + getNotesForInstance("VULNERABILITY", scope.vulnerabilityIds ?? []), + getNotesForInstance("REMEDIATION", scope.remediationIds ?? []), + getNotesForInstance( + "DEVICE_GROUP_MATCHING", + scope.deviceGroupMatchingIds ?? [], + ), + Promise.all((scope.assetIds ?? []).map(getNotesForAsset)).then( + (results) => results.flat(), + ), + ]); - return getRelevantNotes({ deviceGroupIds: [deviceGroupId] }); + const byId = new Map(); + for (const note of [ + ...persistent, + ...vulnerabilities, + ...remediations, + ...matchings, + ...assets, + ]) { + byId.set(note.id, note); + } + return [...byId.values()]; } From 40c404bc557328f963cb0f3bd235101b2f0c3bab Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 18:56:40 -0400 Subject: [PATCH 11/12] format --- prisma/seed.ts | 2 +- src/components/status-form.tsx | 5 +---- src/features/assets/components/asset.tsx | 3 +-- src/features/assets/params.ts | 4 +--- src/features/inbox/agent/match.ts | 1 - src/features/inbox/agent/vex/index.ts | 6 ++---- src/features/inbox/agent/vex/tools.ts | 2 +- src/features/notes/server/get-relevant-notes.ts | 2 +- .../vulnerabilities/components/prioritized-columns.tsx | 4 ++-- 9 files changed, 10 insertions(+), 19 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index f3754702..30fd5097 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -48,7 +48,7 @@ const SAMPLE_DEVICE_GROUPS = [ version: "unknown", }, // ── EOL OS platforms (exist for vulnerability targeting; no assets use these - // as primary CPE, but EternalBlue affectedDeviceGroups connects here too) + // as primary CPE, but EternalBlue affectedDeviceGroups connects here too) { cpe: "cpe:2.3:o:microsoft:windows_7:-:*:*:*:*:*:*:*", manufacturer: "Microsoft", diff --git a/src/components/status-form.tsx b/src/components/status-form.tsx index e9f1adb5..e3d6e2ab 100644 --- a/src/components/status-form.tsx +++ b/src/components/status-form.tsx @@ -37,10 +37,7 @@ export const StatusFormBase = ({ }: { id: string; initialStatus: IssueStatus; - onUpdate: (input: { - id: string; - status: IssueStatus; - }) => Promise; + onUpdate: (input: { id: string; status: IssueStatus }) => Promise; className?: string; }) => { const [status, setStatus] = useState(initialStatus); diff --git a/src/features/assets/components/asset.tsx b/src/features/assets/components/asset.tsx index 73a19331..506642ea 100644 --- a/src/features/assets/components/asset.tsx +++ b/src/features/assets/components/asset.tsx @@ -173,8 +173,7 @@ const TabulatedVulnList = ({ assetId }: TabulatedVulnListProps) => { const [currentTab, setCurrentTab] = useState(params.issueStatus); const handleUpdateTab = (newStatus: string) => { - const issueStatus = - IssueStatus[newStatus as keyof typeof IssueStatus]; + const issueStatus = IssueStatus[newStatus as keyof typeof IssueStatus]; if (issueStatus === undefined) { return; } diff --git a/src/features/assets/params.ts b/src/features/assets/params.ts index 2acabe24..224a2e0e 100644 --- a/src/features/assets/params.ts +++ b/src/features/assets/params.ts @@ -16,9 +16,7 @@ for (const status of Object.values(IssueStatus)) { export const assetDetailParams = { ...issueStatusPageParams, - issueStatus: parseAsStringEnum( - Object.values(IssueStatus), - ) + issueStatus: parseAsStringEnum(Object.values(IssueStatus)) .withDefault(IssueStatus.AFFECTED) .withOptions({ clearOnDefault: true }), }; diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 137ab805..125d9093 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -543,4 +543,3 @@ export async function applyDecisions( return summary; } - diff --git a/src/features/inbox/agent/vex/index.ts b/src/features/inbox/agent/vex/index.ts index c0fcb38b..64e151fd 100644 --- a/src/features/inbox/agent/vex/index.ts +++ b/src/features/inbox/agent/vex/index.ts @@ -18,7 +18,7 @@ export async function sortVulnerabilities( const issueIds = context.issues.map((i) => i.issueId); const schema = buildVexSchema(issueIds); - const TOOL_NAME = "update_and_create_issues"; + const TOOL_NAME = "update_and_create_issues"; const recordTool = tool(async () => "ok", { name: TOOL_NAME, description: @@ -39,9 +39,7 @@ export async function sortVulnerabilities( { role: "user", content: context.markdown }, ]); - const call = res.tool_calls?.find( - (c) => c.name === TOOL_NAME, - ); + const call = res.tool_calls?.find((c) => c.name === TOOL_NAME); if (!call) return {}; const parsed = schema.safeParse(call.args); diff --git a/src/features/inbox/agent/vex/tools.ts b/src/features/inbox/agent/vex/tools.ts index a132d240..4f9125a5 100644 --- a/src/features/inbox/agent/vex/tools.ts +++ b/src/features/inbox/agent/vex/tools.ts @@ -1,4 +1,4 @@ -// the structured output schema the agent's tool call is validated against, +// the structured output schema the agent's tool call is validated against, // // Example: // { diff --git a/src/features/notes/server/get-relevant-notes.ts b/src/features/notes/server/get-relevant-notes.ts index 9f49f34d..fad69e30 100644 --- a/src/features/notes/server/get-relevant-notes.ts +++ b/src/features/notes/server/get-relevant-notes.ts @@ -1,7 +1,7 @@ // Central helper for matching notes to VIPER models. // // A Note is relevant to an object either because it directly references that -// object via targetModel + instanceId, or via EntityFilterMatch +// object via targetModel + instanceId, or via EntityFilterMatch import "server-only"; import type { NoteStatus, ScopeTargetModel } from "@/generated/prisma"; diff --git a/src/features/vulnerabilities/components/prioritized-columns.tsx b/src/features/vulnerabilities/components/prioritized-columns.tsx index 10497496..dcbf7b47 100644 --- a/src/features/vulnerabilities/components/prioritized-columns.tsx +++ b/src/features/vulnerabilities/components/prioritized-columns.tsx @@ -116,8 +116,8 @@ export const issueColumns: ColumnDef[] = [ row.original.asset ? getAssetRoleLabel(row.original.asset) : "Device Group", - // TODO: Consider a backend change where device group issues still get displayed - // in the frontend as one issue per asset in that device group + // TODO: Consider a backend change where device group issues still get displayed + // in the frontend as one issue per asset in that device group }, { accessorKey: "status", From 7663869a24015d9b64706863ca4b7337d10c1815 Mon Sep 17 00:00:00 2001 From: Cassidy Diamond Date: Mon, 6 Jul 2026 19:22:12 -0400 Subject: [PATCH 12/12] coderabbit suggestion --- src/features/inbox/agent/vex/context.ts | 5 ++++- src/features/notes/server/get-relevant-notes.ts | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/features/inbox/agent/vex/context.ts b/src/features/inbox/agent/vex/context.ts index 13d80d80..5e116919 100644 --- a/src/features/inbox/agent/vex/context.ts +++ b/src/features/inbox/agent/vex/context.ts @@ -64,7 +64,8 @@ export async function gatherVexContext( }, }, issues: { where: { deviceGroupMatchingId: { not: null } } }, - // TODO/cassidy: should this include all issues? + // TODO: continue testing, may need to include asset-level issues + // here as well }, }, }, @@ -97,6 +98,8 @@ export async function gatherVexContext( const matchings = [...matchingsById.values()]; // Resolve matchings → concrete device groups (with their assets) in one query. + // TODO: if there's a device group with unknown version, that needs to create + // an UNDER_INVESTIGATION issue const candidateGroups = matchings.length > 0 ? await prisma.deviceGroup.findMany({ diff --git a/src/features/notes/server/get-relevant-notes.ts b/src/features/notes/server/get-relevant-notes.ts index fad69e30..53fe1da7 100644 --- a/src/features/notes/server/get-relevant-notes.ts +++ b/src/features/notes/server/get-relevant-notes.ts @@ -99,9 +99,7 @@ export async function getRelevantNotes( "DEVICE_GROUP_MATCHING", scope.deviceGroupMatchingIds ?? [], ), - Promise.all((scope.assetIds ?? []).map(getNotesForAsset)).then( - (results) => results.flat(), - ), + getNotesForInstance("ASSET", scope.assetIds ?? []), ]); const byId = new Map();