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..108a0bdf --- /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_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 e9b7c7c3..33381e89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { watchedTickets TicketWatch[] ticketSeen TicketSeen[] ticketActivities TicketActivity[] + notes Note[] @@unique([email]) @@index([departmentId]) @@ -286,7 +287,6 @@ model DeviceGroup { @@map("device_group") } - model DeviceGroupMatching { id String @id @default(cuid()) @@ -303,6 +303,7 @@ model DeviceGroupMatching { remediations Remediation[] @relation("RemediationMatchings") advisories Advisory[] @relation("AdvisoryMatchings") deviceArtifacts DeviceArtifact[] @relation("DeviceArtifactMatchings") + issues Issue[] notificationMappings NotificationDeviceGroupMapping[] createdAt DateTime @default(now()) @@ -456,6 +457,7 @@ model ArtifactWrapper { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([remediationId]) @@index([deviceArtifactId]) @@index([userId]) @@ -487,9 +489,10 @@ model Artifact { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@unique([wrapperId, versionNumber]) @@index([wrapperId]) @@index([userId]) - @@unique([wrapperId, versionNumber]) @@map("artifact") } @@ -601,32 +604,125 @@ 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) + status IssueStatus @default(AFFECTED) + notAffectedJustification NotAffectedJustification? + statusConfidence ConfidenceLevel? + statusNotes String? @db.Text + issueRemediations IssueRemediation[] workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues") - - status IssueStatus @default(ACTIVE) + 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_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 + + 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()) artifacts ArtifactWrapper[] @@ -854,7 +950,7 @@ model Advisory { upstreamUrl String? @unique severity Severity summary String? @db.Text - status IssueStatus @default(ACTIVE) + status IssueStatus @default(AFFECTED) tlp Tlp? publishedAt DateTime? createdAt DateTime @default(now()) diff --git a/prisma/seed.ts b/prisma/seed.ts index 26477765..30fd5097 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: "ACTIVE", - }, - }); - 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); 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/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/ { }); 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..e3d6e2ab 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 }) => { 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..506642ea 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 { @@ -204,23 +205,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..224a2e0e 100644 --- a/src/features/assets/params.ts +++ b/src/features/assets/params.ts @@ -17,6 +17,6 @@ for (const status of Object.values(IssueStatus)) { export const assetDetailParams = { ...issueStatusPageParams, issueStatus: parseAsStringEnum(Object.values(IssueStatus)) - .withDefault(IssueStatus.ACTIVE) + .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/inbox/agent/vex/__tests__/vex.test.ts b/src/features/inbox/agent/vex/__tests__/vex.test.ts new file mode 100644 index 00000000..16ec33e1 --- /dev/null +++ b/src/features/inbox/agent/vex/__tests__/vex.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +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. +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/vex/context.ts b/src/features/inbox/agent/vex/context.ts new file mode 100644 index 00000000..5e116919 --- /dev/null +++ b/src/features/inbox/agent/vex/context.ts @@ -0,0 +1,394 @@ +// 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 { getRelevantNotes } from "@/features/notes/server/get-relevant-notes"; +import prisma from "@/lib/db"; +import { + deviceGroupWhereForMatching, + type MatchingLike, + matchingAppliesToDeviceGroup, +} from "@/lib/device-matching"; +import { + deviceGroupLabel, + deviceGroupMatchingLabel, + deviceGroupToMarkdown, +} from "@/lib/string-utils"; + +/** 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 } } }, + // TODO: continue testing, may need to include asset-level issues + // here as well + }, + }, + }, + }, + remediations: { + include: { + remediation: 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 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 v of vulnerabilities) { + for (const dgm of v.deviceGroupMatchings) matchingsById.set(dgm.id, dgm); + } + 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({ + 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 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), + deviceGroupMatchingIds: matchings.map((m) => m.id), + assetIds: allAssetIds, + }); + + // 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_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 ─────────────────────────────────────────────────────────── + +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). + - 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.`; diff --git a/src/features/inbox/agent/vex/index.ts b/src/features/inbox/agent/vex/index.ts new file mode 100644 index 00000000..64e151fd --- /dev/null +++ b/src/features/inbox/agent/vex/index.ts @@ -0,0 +1,62 @@ +// 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 TOOL_NAME = "update_and_create_issues"; + const recordTool = tool(async () => "ok", { + name: TOOL_NAME, + description: + "Record the issue status determination for each issue, keyed by 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 === TOOL_NAME); + 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..f586585f --- /dev/null +++ b/src/features/inbox/agent/vex/process_output.ts @@ -0,0 +1,144 @@ +// 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. + */ +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..4f9125a5 --- /dev/null +++ b/src/features/inbox/agent/vex/tools.ts @@ -0,0 +1,114 @@ +// the structured output schema the agent's tool call is validated against, +// +// 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/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/notes/server/get-relevant-notes.ts b/src/features/notes/server/get-relevant-notes.ts new file mode 100644 index 00000000..53fe1da7 --- /dev/null +++ b/src/features/notes/server/get-relevant-notes.ts @@ -0,0 +1,116 @@ +// 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 + +import "server-only"; +import type { NoteStatus, ScopeTargetModel } from "@/generated/prisma"; +import prisma from "@/lib/db"; + +/** 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; +}; + +const NOTE_SELECT = { + id: true, + text: true, + status: true, + targetModel: true, + instanceId: true, +} as const; + +/** + * Notes matching one or more objects of a single targetModel, by id. Does not + * include PERSISTENT notes (see getRelevantNotes for that). + */ +export async function getNotesForInstance( + targetModel: ScopeTargetModel, + ids: string[], +): Promise { + if (ids.length === 0) return []; + + // TODO(EntityFilterMatch): VW-358 -- also include notes that match + // via EntityFilterMatch + + return prisma.note.findMany({ + where: { targetModel, instanceId: { in: ids } }, + 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 []; +} + +/** + * Notes matching a single asset. Today: notes attached directly to the asset. + */ +export async function getNotesForAsset( + assetId: string, +): Promise { + // 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]); +} + +/** + * In-scope entity ids, grouped by the model they belong to. Any omitted or + * empty list simply contributes no references. + */ +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 { + 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 ?? [], + ), + getNotesForInstance("ASSET", scope.assetIds ?? []), + ]); + + const byId = new Map(); + for (const note of [ + ...persistent, + ...vulnerabilities, + ...remediations, + ...matchings, + ...assets, + ]) { + byId.set(note.id, note); + } + return [...byId.values()]; +} 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..dcbf7b47 100644 --- a/src/features/vulnerabilities/components/prioritized-columns.tsx +++ b/src/features/vulnerabilities/components/prioritized-columns.tsx @@ -112,7 +112,12 @@ 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", + // 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", @@ -133,10 +138,12 @@ export const issueColumns: ColumnDef[] = [ cell: ({ row }) => ( 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 fa4598c8..422af606 100644 --- a/src/lib/prisma-client-extensions.ts +++ b/src/lib/prisma-client-extensions.ts @@ -2,7 +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, resolveMatches } from "./device-matching"; import { getBaseUrl } from "./url-utils"; import { sendWebhook } from "./utils"; @@ -82,49 +81,21 @@ 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. + // Open one baseline issue per DeviceGroupMatching linked to this + // vulnerability, regardless of whether any assets exist yet. const matchings = await client.deviceGroupMatching.findMany({ where: { vulnerabilities: { some: { id: vulnerabilityId } } }, - select: { - 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 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 }, - }); - - if (assets.length > 0) { - await client.issue.createMany({ - data: assets.map((asset) => ({ - vulnerabilityId, - assetId: asset.id, - })), - }); - } - } } 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 c6818ffc..b56a8aff 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -16,6 +16,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"), }, }, });