diff --git a/package-lock.json b/package-lock.json index ef061632..1db6a246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,6 @@ "@trpc/client": "^11.6.0", "@trpc/server": "^11.6.0", "@trpc/tanstack-react-query": "^11.6.0", - "@types/semver": "^7.7.1", "@xyflow/react": "^12.8.6", "ai": "5.0.100", "better-auth": "1.4.9", @@ -108,6 +107,7 @@ "@types/nodemailer": "^6.4.21", "@types/react": "^19", "@types/react-dom": "^19", + "@types/semver": "^7.7.1", "@types/supertest": "^6.0.3", "@types/toposort": "^2.0.7", "@types/turndown": "^5.0.6", @@ -12531,6 +12531,7 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, "license": "MIT" }, "node_modules/@types/stack-utils": { diff --git a/package.json b/package.json index 3ebbca68..11eb7afc 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "@trpc/client": "^11.6.0", "@trpc/server": "^11.6.0", "@trpc/tanstack-react-query": "^11.6.0", - "@types/semver": "^7.7.1", "@xyflow/react": "^12.8.6", "ai": "5.0.100", "better-auth": "1.4.9", @@ -122,6 +121,7 @@ "@types/nodemailer": "^6.4.21", "@types/react": "^19", "@types/react-dom": "^19", + "@types/semver": "^7.7.1", "@types/supertest": "^6.0.3", "@types/toposort": "^2.0.7", "@types/turndown": "^5.0.6", diff --git a/prisma/migrations/20260629161924_notification_device_group_matching/migration.sql b/prisma/migrations/20260629161924_notification_device_group_matching/migration.sql new file mode 100644 index 00000000..eed3db7b --- /dev/null +++ b/prisma/migrations/20260629161924_notification_device_group_matching/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `deviceGroupId` on the `notification_device_group_mapping` table. All the data in the column will be lost. + - A unique constraint covering the columns `[notificationId,deviceGroupMatchingId]` on the table `notification_device_group_mapping` will be added. If there are existing duplicate values, this will fail. + - Added the required column `deviceGroupMatchingId` to the `notification_device_group_mapping` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "notification_device_group_mapping" DROP CONSTRAINT "notification_device_group_mapping_deviceGroupId_fkey"; + +-- DropIndex +DROP INDEX "notification_device_group_mapping_notificationId_deviceGrou_key"; + +-- AlterTable +ALTER TABLE "notification_device_group_mapping" DROP COLUMN "deviceGroupId", +ADD COLUMN "deviceGroupMatchingId" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "notification_device_group_mapping_notificationId_deviceGrou_key" ON "notification_device_group_mapping"("notificationId", "deviceGroupMatchingId"); + +-- AddForeignKey +ALTER TABLE "notification_device_group_mapping" ADD CONSTRAINT "notification_device_group_mapping_deviceGroupMatchingId_fkey" FOREIGN KEY ("deviceGroupMatchingId") REFERENCES "device_group_matching"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260702173451_work_order_id_device_group_matching_id/migration.sql b/prisma/migrations/20260702173451_work_order_id_device_group_matching_id/migration.sql new file mode 100644 index 00000000..5acd5a8c --- /dev/null +++ b/prisma/migrations/20260702173451_work_order_id_device_group_matching_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[workOrderTicketId,deviceGroupMatchingId]` on the table `notification_device_group_mapping` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "ndg_mapping_workorder_devicegroupmatching_key" ON "notification_device_group_mapping"("workOrderTicketId", "deviceGroupMatchingId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b4cb228..e9b7c7c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -280,14 +280,7 @@ model DeviceGroup { updatedAt DateTime @updatedAt deviceGroupHistories DeviceGroupHistory[] assets Asset[] - // Vulnerabilities / remediations / advisories / device artifacts connect via - // DeviceGroupMatching now, not a direct relation. notificationMappings is from - // main's notifications feature and stays. - notificationMappings NotificationDeviceGroupMapping[] - - // NOTE: Postgres treats NULLs as distinct, so this composite unique will NOT - // prevent duplicate rows when versionId is null. Identity resolution is done - // in the app via resolveDeviceGroup() (findFirst + create in a transaction). + @@unique([vendorId, productId, versionId, versionStatus], name: "device_group_identity") @@index([vendorId, productId]) @@map("device_group") @@ -310,6 +303,7 @@ model DeviceGroupMatching { remediations Remediation[] @relation("RemediationMatchings") advisories Advisory[] @relation("AdvisoryMatchings") deviceArtifacts DeviceArtifact[] @relation("DeviceArtifactMatchings") + notificationMappings NotificationDeviceGroupMapping[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1163,8 +1157,8 @@ model Notification { sources NotificationSource[] reads NotificationRead[] - assets NotificationAssetMapping[] - deviceGroups NotificationDeviceGroupMapping[] + assets NotificationAssetMapping[] + deviceGroupsMatchings NotificationDeviceGroupMapping[] vulnerabilities NotificationVulnerabilityMapping[] remediations NotificationRemediationMapping[] @@ -1251,14 +1245,15 @@ model NotificationDeviceGroupMapping { 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) + deviceGroupMatchingId String + deviceGroupMatching DeviceGroupMatching @relation(fields:[deviceGroupMatchingId], references: [id], onDelete: Cascade) confidence ConfidenceLevel? reasonWhy String? - @@unique([notificationId, deviceGroupId]) - @@unique([workOrderTicketId, deviceGroupId], map: "ndg_mapping_workorder_devicegroup_key") + @@unique([notificationId, deviceGroupMatchingId]) + @@unique([workOrderTicketId, deviceGroupMatchingId], map: "ndg_mapping_workorder_devicegroupmatching_key") @@index([workOrderTicketId]) + @@map("notification_device_group_mapping") } diff --git a/scripts/seed-notifications.ts b/scripts/seed-notifications.ts index 0e621fd5..e00e49a3 100644 --- a/scripts/seed-notifications.ts +++ b/scripts/seed-notifications.ts @@ -81,7 +81,12 @@ async function seedDeviceGroups(userId: string) { const existing = await prisma.deviceGroup.findFirst({ where: identity }); const deviceGroup = existing ?? (await prisma.deviceGroup.create({ data: identity })); - + const existingMatching = await prisma.deviceGroupMatching.findFirst({ + where: { vendorId: vendor.id, productId: product.id, versionId: null }, + }); + const deviceGroupMatching = + existingMatching ?? + (await prisma.deviceGroupMatching.create({ data: identity })); const existingAssets = await prisma.asset.count({ where: { deviceGroupId: deviceGroup.id }, }); @@ -104,7 +109,11 @@ async function seedDeviceGroups(userId: string) { console.log( ` āœ… ${dg.vendor} ${dg.product} (${deviceGroup.id}) — ${dg.assetCount} assets`, ); - results.push({ ...dg, deviceGroupId: deviceGroup.id }); + results.push({ + ...dg, + deviceGroupId: deviceGroup.id, + deviceGroupMatchingId: deviceGroupMatching.id, + }); } return results; @@ -355,7 +364,7 @@ async function getSeedUser() { async function seedNotifications( _userId: string, - deviceGroups: Array<{ deviceGroupId: string }>, + deviceGroups: Array<{ deviceGroupMatchingId: string }>, ) { console.log("\n🌱 Seeding notifications..."); @@ -407,7 +416,7 @@ async function seedNotifications( await prisma.notificationDeviceGroupMapping.create({ data: { notificationId: notification.id, - deviceGroupId: dg.deviceGroupId, + deviceGroupMatchingId: dg.deviceGroupMatchingId, confidence: Math.random() > 0.3 ? ConfidenceLevel.Confirmed diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index 183fd0fb..8d8c35af 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -3,84 +3,323 @@ import "server-only"; import type { Prisma } from "@/generated/prisma"; import prisma from "@/lib/db"; -import type { ExtractedDeviceGroup, ExtractResult } from "./extract"; +import { normalizeName } from "@/lib/router-utils"; +import type { + ExtractedAsset, + ExtractedDeviceGroup, + ExtractedRemediation, + ExtractedVulnerability, + ExtractResult, +} from "./extract"; const TOP_K = 5; -export type DeviceGroupCandidate = { +export type DeviceGroupMatchingCandidate = { id: string; - cpe: string[]; manufacturer: string | null; modelName: string | null; version: string | null; + versionRange: string | null; +}; + +export type VulnerabilityCandidate = { + id: string; + cveId: string | null; + description: string | null; + cvssScore: number | null; + cvssVector: string | null; +}; + +export type RemediationCandidate = { + id: string; + linkedCveId: string | null; + description: string | null; +}; + +export type AssetCandidate = { + id: string; + ip: string | null; + hostname: string | null; + macAddress: string | null; + serialNumber: string | null; }; export type Candidates = { // Parallel to ExtractResult: one candidate list per extracted entity, in order. deviceGroups: Array<{ extracted: ExtractedDeviceGroup; - matches: DeviceGroupCandidate[]; + matches: DeviceGroupMatchingCandidate[]; + }>; + vulnerabilities: Array<{ + extracted: ExtractedVulnerability; + matches: VulnerabilityCandidate[]; + }>; + remediations: Array<{ + extracted: ExtractedRemediation; + matches: RemediationCandidate[]; + }>; + assets: Array<{ + extracted: ExtractedAsset; + matches: AssetCandidate[]; }>; }; +const nameOrClauses = (term: string) => [ + { canonicalName: { contains: term, mode: "insensitive" as const } }, + { canonicalDisplayName: { contains: term, mode: "insensitive" as const } }, + { nameMappings: { has: normalizeName(term) } }, +]; + +const vendorNameOr = (term: string): Prisma.VendorWhereInput[] => + nameOrClauses(term); +const productNameOr = (term: string): Prisma.ProductWhereInput[] => + nameOrClauses(term); + // Returns top-K candidates per extracted entity. -async function searchDeviceGroup( +async function searchDeviceGroupMatching( extracted: ExtractedDeviceGroup, -): Promise { - const terms = [ - extracted.cpe, - extracted.manufacturer, - extracted.modelName, - ].filter((t): t is string => !!t && t.trim().length > 0); +): Promise { + const matched = new Map(); + + // find the owned DeviceGrop with the identifier first, then surface the DeviceGroupMatching + // sharing its identy as match + const identifierWhere: Prisma.DeviceGroupWhereInput[] = []; + if (extracted.cpe) { + identifierWhere.push({ cpe: { has: extracted.cpe } }); + } + if (extracted.udi) { + identifierWhere.push({ udi: extracted.udi }); + } - if (terms.length === 0) return []; + if (identifierWhere.length > 0) { + const deviceGroup = await prisma.deviceGroup.findFirst({ + where: { OR: identifierWhere }, + select: { vendorId: true, productId: true, versionId: true }, + }); + if (deviceGroup?.vendorId) { + const matchingSelect = { + id: true, + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { select: { canonicalDisplayName: true } }, + versionRange: true, + } as const; - const or: Prisma.DeviceGroupWhereInput[] = []; + const identity = { + vendorId: deviceGroup.vendorId, + productId: deviceGroup.productId, + versionId: deviceGroup.versionId, + }; + + const existingMatching = await prisma.deviceGroupMatching.findFirst({ + where: identity, + select: matchingSelect, + }); + + const matching = + existingMatching ?? + (await prisma.deviceGroupMatching.create({ + data: identity, + select: matchingSelect, + })); + + matched.set(matching.id, { + id: matching.id, + manufacturer: matching.vendor?.canonicalDisplayName ?? null, + modelName: matching.product?.canonicalDisplayName ?? null, + version: matching.version?.canonicalDisplayName ?? null, + versionRange: matching.versionRange, + }); + } + } + + const terms = [extracted.manufacturer, extracted.modelName].filter( + (t): t is string => !!t && t.trim().length > 0, + ); + + if (terms.length === 0) return [...matched.values()]; + + const or: Prisma.DeviceGroupMatchingWhereInput[] = []; // TODO: consider something like a fuzzy search? // or an embedding. For example, if the identified manufacturer is Draeger Inc, and the db manufactuerer is "Draeger", this contains fails for (const term of terms) { - const insensitive = { contains: term, mode: "insensitive" as const }; // Identity now lives in canonical Vendor/Product rows and the cpe[] array. // cpe is a String[], which supports exact-element `has` (not substring). or.push( - { cpe: { has: term } }, - { vendor: { canonicalName: insensitive } }, - { vendor: { canonicalDisplayName: insensitive } }, - { product: { canonicalName: insensitive } }, - { product: { canonicalDisplayName: insensitive } }, + { vendor: { OR: vendorNameOr(term) } }, + { product: { OR: productNameOr(term) } }, ); } - const rows = await prisma.deviceGroup.findMany({ + const rows = await prisma.deviceGroupMatching.findMany({ where: { OR: or }, select: { id: true, - cpe: true, vendor: { select: { canonicalDisplayName: true } }, product: { select: { canonicalDisplayName: true } }, version: { select: { canonicalDisplayName: true } }, + versionRange: true, + }, + take: TOP_K, + }); + + for (const row of rows) { + matched.set(row.id, { + id: row.id, + manufacturer: row.vendor?.canonicalDisplayName ?? null, + modelName: row.product?.canonicalDisplayName ?? null, + version: row.version?.canonicalDisplayName ?? null, + versionRange: row.versionRange, + }); + } + return [...matched.values()]; +} + +async function searchVulnerability( + extracted: ExtractedVulnerability, +): Promise { + const or: Prisma.VulnerabilityWhereInput[] = []; + if (extracted.cveId) { + or.push({ cveId: { contains: extracted.cveId, mode: "insensitive" } }); + } + + if (or.length === 0) return []; + + const rows = await prisma.vulnerability.findMany({ + where: { OR: or }, + select: { + id: true, + cveId: true, + description: true, + cvssScore: true, + cvssVector: true, + }, + take: TOP_K, + }); + + return rows.map((row) => ({ + id: row.id, + cveId: row.cveId ?? null, + description: row.description ?? null, + cvssScore: row.cvssScore ?? null, + cvssVector: row.cvssVector ?? null, + })); +} + +async function searchRemediation( + extracted: ExtractedRemediation, +): Promise { + const or: Prisma.RemediationWhereInput[] = []; + if (extracted.linkedCveId) { + or.push({ + vulnerability: { + cveId: { contains: extracted.linkedCveId, mode: "insensitive" }, + }, + }); + } + if (extracted.description) { + const insensitive = { + contains: extracted.description, + mode: "insensitive" as const, + }; + or.push({ description: insensitive }, { narrative: insensitive }); + } + if (or.length === 0) return []; + const rows = await prisma.remediation.findMany({ + where: { OR: or }, + select: { + id: true, + description: true, + vulnerability: { select: { cveId: true } }, + deviceGroupMatchings: { + select: { + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + }, + take: 1, + }, }, take: TOP_K, }); return rows.map((row) => ({ id: row.id, - cpe: row.cpe, - manufacturer: row.vendor?.canonicalDisplayName ?? null, - modelName: row.product?.canonicalDisplayName ?? null, - version: row.version?.canonicalDisplayName ?? null, + linkedCveId: row.vulnerability?.cveId ?? null, + description: row.description ?? null, + })); +} + +async function searchAsset( + extracted: ExtractedAsset, +): Promise { + const or: Prisma.AssetWhereInput[] = []; + if (extracted.ip) { + or.push({ ip: { contains: extracted.ip, mode: "insensitive" } }); + } + if (extracted.hostname) { + or.push({ + hostname: { contains: extracted.hostname, mode: "insensitive" }, + }); + } + if (or.length === 0) return []; + + const rows = await prisma.asset.findMany({ + where: { OR: or }, + select: { + id: true, + ip: true, + hostname: true, + macAddress: true, + serialNumber: true, + deviceGroup: { + select: { + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + }, + }, + }, + take: TOP_K, + }); + + return rows.map((row) => ({ + id: row.id, + ip: row.ip ?? null, + hostname: row.hostname ?? null, + macAddress: row.macAddress ?? null, + serialNumber: row.serialNumber ?? null, })); } export async function searchCandidates( extracted: ExtractResult, ): Promise { - const deviceGroups = await Promise.all( - extracted.deviceGroups.map(async (dg) => ({ - extracted: dg, - matches: await searchDeviceGroup(dg), - })), - ); + const [deviceGroups, vulnerabilities, remediations, assets] = + await Promise.all([ + Promise.all( + extracted.deviceGroups.map(async (dg) => ({ + extracted: dg, + matches: await searchDeviceGroupMatching(dg), + })), + ), + Promise.all( + extracted.vulnerabilities.map(async (v) => ({ + extracted: v, + matches: await searchVulnerability(v), + })), + ), + Promise.all( + extracted.remediations.map(async (r) => ({ + extracted: r, + matches: await searchRemediation(r), + })), + ), + Promise.all( + extracted.assets.map(async (a) => ({ + extracted: a, + matches: await searchAsset(a), + })), + ), + ]); - return { deviceGroups }; + return { deviceGroups, vulnerabilities, remediations, assets }; } diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index 074d466d..a24ee8a5 100644 --- a/src/features/inbox/agent/extract.ts +++ b/src/features/inbox/agent/extract.ts @@ -9,8 +9,6 @@ import { fetchPdfAttachments } from "../utils"; // A device group referenced by a notification. All fields are optional so the // model can emit whatever identifiers it finds; downstream code skips entries // with no usable identifier. -// TODO: add new fields like UDI after VW-283 gets merged in -// TODO: add new fields like versionRange after VW-283 gets merged in (used to create a DeviceGroupMatchObject?) // vers schema, if we provide that + maybe a skill to use it if necessary, has a way to provide multiple OR versions // https://www.packageurl.org/docs/vers/schemas // TODO: if we can find more data to support this, add something like serialRange @@ -18,35 +16,87 @@ import { fetchPdfAttachments } from "../utils"; // something like externalId on an Integration model export const extractedDeviceGroupSchema = z.object({ cpe: z.string().nullish(), + udi: z.string().nullish(), manufacturer: z.string().nullish(), modelName: z.string().nullish(), version: z.string().nullish(), + versionRange: z.string().nullish(), +}); + +export const extractedVulnerabilitySchema = z.object({ + cveId: z + .string() + .regex(/^CVE-\d{4}-\d{4,}$/i) + .nullish(), + cvssScore: z.number().min(0).max(10).nullish(), + cvssVector: z.string().nullish(), +}); + +export const extractedRemediationSchema = z.object({ + linkedCveId: z + .string() + .regex(/^CVE-\d{4}-\d{4,}$/i) + .nullish(), + description: z.string().nullish(), +}); + +export const extractedAssetSchema = z.object({ + ip: z.string().nullish(), + hostname: z.string().nullish(), + macAddress: z.string().nullish(), + serialNumber: z.string().nullish(), }); -// TODO: extend this, which has just device groups for now, with vulnerabilities -// and remediations export const extractSchema = z.object({ deviceGroups: z.array(extractedDeviceGroupSchema), + vulnerabilities: z.array(extractedVulnerabilitySchema), + remediations: z.array(extractedRemediationSchema), + assets: z.array(extractedAssetSchema), }); export type ExtractedDeviceGroup = z.infer; +export type ExtractedVulnerability = z.infer< + typeof extractedVulnerabilitySchema +>; +export type ExtractedRemediation = z.infer; +export type ExtractedAsset = z.infer; export type ExtractResult = z.infer; const MODEL = "claude-haiku-4-5-20251001"; +// cvssScore format reference: https://nvd.nist.gov/vuln-metrics/cvss const SYSTEM_PROMPT = `You are an extraction agent for a hospital cybersecurity platform that reads security notifications (advisories, recalls, update notices) and their PDF attachments. -Your task: extract the DEVICE GROUPS that the notification is about. A device group is a class of affected product, identified by some combination of: +Your task: extract the following Entities that the notification is about. + +FOR DEVICE GROUPS: A device group is a class of affected product, identified by some combination of: - CPE string (e.g. "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*") +- UDI: the FDA-assigned Device Identifier (DI) - a fixed code identifying the labeler and specific device model (e.g, "04048675556176"), distinct from a CPE. If the notification quotes a full UDI label string that also encodes a Production Identifier (PI) in GS1 format "(01) 00855361005016", extract ONLY the DI segment. - manufacturer / vendor (e.g. "Philips", "GE Healthcare") - model name (e.g. "IntelliVue MX40", "Alaris Pump") - version / firmware (e.g. "2.3.1") +- versionRange: if the notification specifies a range (e.g. "affects firmware 2.1 through 2.3"), express it as a VERS string (e.g, "vers:generic />2.1|<=2.3"). Omit if only a single exact version is mentioned. + +FOR VULNERABILITIES: CVEids or security issues explicitly named +- cveId: must match format CVE-YYYY-NNNNN (e.g CVE-2020-25175). Omit if not explicitly named. +- cvssScore: the CVSS base score(0.0-10.0), only if explicity stated. Omit if not given. +- cvssVector: the CVSS vector string (e.g CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H), only if explicitly stated. Omit if not given. + +FOR REMEDIATIONS: Patches, firmware updates, or mitigations explicitly described +- linkedCveId: must match format CVE-YYYY-NNNNN (e.g CVE-2020-25175). Omit if not explicitly named. +- description: a short description of the fix (e.g Apply GE Healthcare ICS security controls per CISA advisory) + +FOR ASSETS: Specific devices named by network identity +- ip address: (in IPv4 or IPv6 format) +- hostname: A structured breakdown of standard healthcare hostnames uses a format like: [Location]-[Device][OS]-[ID] (e.g ER-DW-0452) +- macAddress: A MAC address is a 12-character physical hardware identifier formatted in hexadecimal (using 0-9 and A-F). For example, a Philips patient monitor network card has the MAC address 00:30:A3:1B:4C:D2. +- serialNumber: A hospital asset serial number uniquely identifies an individual piece of medical, IT, or facility equipment. Serial numbers are assigned by the manufacturer and are immutable, whereas asset tag numbers are assigned by the hospital for tracking purposes (e.g Infusion Pump: SN: 702-839201-A, Defibrillator: SN: US22045981) RULES: -- Extract ONLY device groups explicitly referenced in the notification or its attachments. +- Extract ONLY Entities explicitly referenced in the notification or its attachments. - Omit any field you are unsure about; do not guess or fabricate CPEs, models, or versions. -- If the notification references no specific device, return an empty deviceGroups array. -- Each distinct affected product should be its own entry.`; +- If the notification references no specific info, return an empty array for each entity. +- Each distinct affected product/vulnerability/remediation/asset should be its own entry.`; export async function extractEntities( sourceId: string, diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 41437921..125d9093 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -7,7 +7,14 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { z } from "zod"; import type { ConfidenceLevel } from "@/generated/prisma"; import prisma from "@/lib/db"; -import { cpeToDeviceGroup } from "@/lib/router-utils"; +import { resolveMatchingId } from "@/lib/router-utils"; +import { + addProductAlias, + addVendorAlias, + enrichAssetIdentifiers, + enrichDeviceGroupIdentifiers, + enrichVulnerabilityCvss, +} from "../utils"; import type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -15,22 +22,42 @@ const MODEL = "claude-haiku-4-5-20251001"; // Fields the LLM may set when creating/updating a device group. Kept flat and // optional so the schema stays a top-level object (Anthropic requirement). -const deviceGroupFieldsSchema = z.object({ +const detailsFieldsSchema = z.object({ cpe: z.string().nullish(), + udi: z.string().nullish(), manufacturer: z.string().nullish(), modelName: z.string().nullish(), version: z.string().nullish(), + versionRange: z.string().nullish(), + cveId: z + .string() + .regex(/^CVE-\d{4}-\d{4,}$/i) + .nullish(), + linkedCveId: z + .string() + .regex(/^CVE-\d{4}-\d{4,}$/i) + .nullish(), + description: z.string().nullish(), + cvssScore: z.number().min(0).max(10).nullish(), + cvssVector: z.string().nullish(), + macAddress: z.string().nullish(), + serialNumber: z.string().nullish(), }); const decisionSchema = z.object({ - kind: z.enum(["deviceGroup"]), // TODO: add vulnerability, remediation, maybe asset... + kind: z.enum([ + "deviceGroupMatching", + "vulnerability", + "remediation", + "asset", + ]), op: z.enum(["link", "update", "create"]), // The id of an existing candidate to link/update. Omitted for create. targetId: z.string().nullish(), // The LLM may only express these two — Confirmed is reserved for humans. confidence: z.enum(["NeedsReview", "Matched"]), reasonWhy: z.string(), - fields: deviceGroupFieldsSchema.nullish(), + fields: detailsFieldsSchema.nullish(), }); const matchSchema = z.object({ @@ -46,56 +73,182 @@ export type MatchSummary = { skipped: number; }; -const SYSTEM_PROMPT = `You are a matching agent for a hospital cybersecurity platform. You connect the DEVICE GROUPS referenced in a security notification to records in the database. +const SYSTEM_PROMPT = `You are a matching agent for a hospital cybersecurity platform. You connect entities referenced in a security notification to records in the database. -You are given, for each device group extracted from the notification, a list of candidate database records found by identifier search. +You are given, for each entity extracted from the notification, a list of candidate database records found by identifier search. For each extracted device group, choose exactly ONE action: - "link": the device group clearly matches an existing candidate. Set targetId to that candidate's id. -- "update": the device group matches an existing candidate, but the notification contains additional/missing identifier info worth saving (e.g. a version the record lacks). Set targetId to that candidate's id and put the new values in fields. -- "create": none of the candidates match. Put the device group's identifiers in fields. A cpe is required to create — if you cannot supply one, prefer "link" to the closest candidate or omit the decision entirely. +- "update": the device group matches an existing candidate, but the notification contains additional/missing identifier info worth saving (e.g. a versionRange the record lacks). Set targetId to that candidate's id and put the new values in fields. +- "create": none of the candidates match. Put the device group's identifiers in fields. Manufacturer is required to create — if you cannot supply one, prefer "link" to the closest candidate or omit the decision entirely. + +For each extracted Vulnerability, choose exactly ONE action: +- "link": the vulnerability clearly matches an existing candidate (same CVE id). Set targetId to that candidate's id. +- "update": matches an existing candidate, but the notification has new info worth saving (cvssScore, cvssVector, description). Set targetId and put the new values in fields. + +For each extracted Remediation, choose exactly ONE action: +- "link": the remediation clearly matches an existing candidate. Set targetId to that candidate's id. +- "update": matches an existing candidate, but the notification has new info worth saving (description) Set targetId and put the new value in fields. + +For each extracted Asset, choose exactly ONE action: +- "link": the asset clearly matches an existing candidate (same IP/hostname/Mac Address/serial number). Set targetId to that candidate's id. If the notification states a MAC address or serial number the matched record is missing, include it in fields so it can be saved. CONFIDENCE (you may only use these two): -- "Matched": strong identifier match (same CPE, or same manufacturer + model). +- "Matched": strong identifier match (same CVE id, same IP/hostname/MAC address/serial number, or same manufacturer + model). - "NeedsReview": plausible but uncertain match, or a newly created record that a human should verify. -Always give a concise reasonWhy explaining the match. Only emit decisions you are reasonably confident about; it is fine to return fewer decisions than device groups.`; +Always give a concise reasonWhy explaining the match. Only emit decisions you are reasonably confident about; it is fine to return fewer decisions than extracted entities.`; function renderCandidates(candidates: Candidates): string { - if (candidates.deviceGroups.length === 0) - return "(no device groups extracted)"; - - 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 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)"}`, - ) - .join("\n") - : " - (no candidates found)"; - return `${extractedLine}\n Candidates:\n${matches}`; - }) - .join("\n\n"); + const sections: string[] = []; + + if ( + candidates.deviceGroups.length === 0 && + candidates.vulnerabilities.length === 0 && + candidates.remediations.length === 0 && + candidates.assets.length === 0 + ) { + return "(no entities extracted)"; + } + + if (candidates.deviceGroups.length > 0) { + sections.push( + 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 ?? "?"} | versionRange=${e.versionRange ?? "?"}`; + const matches = + entry.matches.length > 0 + ? entry.matches + .map( + (m) => + ` - id: ${m.id} | manufacturer: ${m.manufacturer ?? "(none)"} | modelName: ${m.modelName ?? "(none)"} | version: ${m.version ?? "(none)"}`, + ) + .join("\n") + : " - (no candidates found)"; + return `${extractedLine}\n Candidates:\n${matches}`; + }) + .join("\n\n"), + ); + } + + if (candidates.vulnerabilities.length > 0) { + sections.push( + "\n" + + candidates.vulnerabilities + .map((entry, i) => { + const e = entry.extracted; + const line = `Vulnerability #${i + 1}: cveId=${e.cveId ?? "?"}`; + const matches = + entry.matches.length > 0 + ? entry.matches + .map( + (m) => + ` - id: ${m.id} | cveId: ${m.cveId ?? "(none)"} | description: ${m.description} ?? "(none)"} | cvssScore: ${m.cvssScore ?? "(none)"} | cvssVector: ${m.cvssVector ?? "(none)"}`, + ) + .join("\n") + : " - (no candidates found)"; + + return `${line}\n Candidates: \n${matches}`; + }) + .join("\n\n"), + ); + } + + if (candidates.remediations.length > 0) { + sections.push( + "\n" + + candidates.remediations + .map((entry, i) => { + const e = entry.extracted; + const line = `Remediations #${i + 1}: linkedtoCveId=${e.linkedCveId ?? "?"} | description=${e.description} ?? "?"}`; + const matches = + entry.matches.length > 0 + ? entry.matches + .map( + (m) => + ` - id: ${m.id} | linkedtoCveId: ${m.linkedCveId ?? "(none)"} | description: ${m.description ?? "(none)"}`, + ) + .join("\n") + : " - (no candidates found)"; + + return `${line}\n Candidates: \n${matches}`; + }) + .join("\n\n"), + ); + } + + if (candidates.assets.length > 0) { + sections.push( + "\n" + + candidates.assets + .map((entry, i) => { + const e = entry.extracted; + const line = `Asset #${i + 1}: ip=${e.ip ?? "?"} | hostname=${e.hostname ?? "?"} | macAddress=${e.macAddress ?? "?"} | serialNumber=${e.serialNumber ?? "?"}`; + const matches = + entry.matches.length > 0 + ? entry.matches + .map( + (m) => + ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname ?? "(none)"} | macAddress: ${m.macAddress ?? "(none)"} | serialNumber=${e.serialNumber ?? "(none)"}`, + ) + .join("\n") + : " - (no candidates found)"; + + return `${line}\n Candidates: \n${matches}`; + }) + .join("\n\n"), + ); + } + + return sections.length > 0 + ? sections.join("\n\n") + : "(No Match Record Found)"; } // Build a Prisma data object from LLM-provided fields, dropping empties. function cleanFields(fields: Decision["fields"]): { - cpe?: string; manufacturer?: string; modelName?: string; version?: string; + versionRange?: string; + cpe?: string; + udi?: string; + cveId?: string; + description?: string; + linkedCveId?: string; + cvssScore?: number; + cvssVector?: string; + macAddress?: string; + serialNumber?: string; } { - const out: Record = {}; + const out: Record = {}; if (!fields) return out; - for (const key of ["cpe", "manufacturer", "modelName", "version"] as const) { + for (const key of [ + "manufacturer", + "modelName", + "version", + "versionRange", + "cveId", + "linkedCveId", + "cpe", + "udi", + "description", + "cvssScore", + "cvssVector", + "macAddress", + "serialNumber", + ] as const) { const val = fields[key]; if (typeof val === "string" && val.trim().length > 0) out[key] = val.trim(); } + if (typeof fields.cvssScore === "number") out.cvssScore = fields.cvssScore; + if ( + typeof fields.cvssVector === "string" && + fields.cvssVector.trim().length > 0 + ) { + out.cvssVector = fields.cvssVector.trim(); + } return out; } @@ -143,83 +296,247 @@ export async function applyDecisions( // Only allow link/update against ids the search actually surfaced — guards // against hallucinated targetIds causing FK errors. - const validIds = new Set( - candidates.deviceGroups.flatMap((e) => e.matches.map((m) => m.id)), - ); + + const validIds = { + deviceGroupMatching: new Set( + candidates.deviceGroups.flatMap((e) => e.matches.map((m) => m.id)), + ), + vulnerability: new Set( + candidates.vulnerabilities.flatMap((e) => e.matches.map((m) => m.id)), + ), + remediation: new Set( + candidates.remediations.flatMap((e) => e.matches.map((m) => m.id)), + ), + asset: new Set( + candidates.assets.flatMap((e) => e.matches.map((m) => m.id)), + ), + }; await prisma.$transaction(async (tx) => { for (const decision of decisions) { - if (decision.kind !== "deviceGroup") { - summary.skipped++; - continue; - } - // Hard guard: the pipeline never writes Confirmed. const confidence: ConfidenceLevel = decision.confidence === "Matched" ? "Matched" : "NeedsReview"; - const upsertMapping = (deviceGroupId: string) => - tx.notificationDeviceGroupMapping.upsert({ + if (decision.kind === "deviceGroupMatching") { + const upsertMapping = (deviceGroupMatchingId: string) => + tx.notificationDeviceGroupMapping.upsert({ + where: { + notificationId_deviceGroupMatchingId: { + notificationId, + deviceGroupMatchingId, + }, + }, + create: { + notificationId, + deviceGroupMatchingId, + confidence, + reasonWhy: decision.reasonWhy, + }, + update: { confidence, reasonWhy: decision.reasonWhy }, + }); + + const enrichDeviceGroup = async ( + deviceGroupMatchingId: string, + data: ReturnType, + ) => { + if (!data.cpe && !data.udi) return; + const matching = await tx.deviceGroupMatching.findUnique({ + where: { id: deviceGroupMatchingId }, + select: { vendorId: true, productId: true, versionId: true }, + }); + if (matching) { + await enrichDeviceGroupIdentifiers(matching, { + cpe: data.cpe, + udi: data.udi, + }); + } + }; + + // link the device group to the notification + if (decision.op === "link") { + if ( + !decision.targetId || + !validIds.deviceGroupMatching.has(decision.targetId) + ) { + summary.skipped++; + continue; + } + await upsertMapping(decision.targetId); + await enrichDeviceGroup( + decision.targetId, + cleanFields(decision.fields), + ); + summary.linked++; + } + // update the device group and link it to the notification + // TODO: consider, we may want to separate this into 'update' and 'update_and_link' actions + else if (decision.op === "update") { + if ( + !decision.targetId || + !validIds.deviceGroupMatching.has(decision.targetId) + ) { + summary.skipped++; + continue; + } + // Vendor/product/version are part of a device group's identity now and + // can't be mutated in place; the only safe enrichment is unioning a new + // CPE into the existing group's cpe[] array. + const data = cleanFields(decision.fields); + + const targetMatching = await tx.deviceGroupMatching.findUnique({ + where: { id: decision.targetId }, + select: { + versionRange: true, + vendorId: true, + productId: true, + versionId: true, + }, + }); + + if ( + data.versionRange && + targetMatching && + !targetMatching.versionRange + ) { + await tx.deviceGroupMatching.update({ + where: { id: decision.targetId }, + data: { versionRange: data.versionRange }, + }); + } + if (targetMatching) { + if (data.manufacturer) { + await addVendorAlias(targetMatching.vendorId, data.manufacturer); + } + if (data.modelName && targetMatching.productId) { + await addProductAlias(targetMatching.productId, data.modelName); + } + } + await upsertMapping(decision.targetId); + await enrichDeviceGroup(decision.targetId, data); + summary.updated++; + } else { + // create + const data = cleanFields(decision.fields); + if (!data.manufacturer) { + summary.skipped++; + continue; + } + const matchingId = await resolveMatchingId({ + vendor: data.manufacturer, + product: data.modelName, + version: data.version, + versionRange: data.versionRange, + hasCpe: false, + }); + await upsertMapping(matchingId); + await enrichDeviceGroup(matchingId, data); + summary.created++; + } + } else if (decision.kind === "vulnerability") { + if ( + !decision.targetId || + !validIds.vulnerability.has(decision.targetId) + ) { + summary.skipped++; + continue; + } + await tx.notificationVulnerabilityMapping.upsert({ where: { - notificationId_deviceGroupId: { notificationId, deviceGroupId }, + notificationId_vulnerabilityId: { + notificationId, + vulnerabilityId: decision.targetId, + }, }, create: { notificationId, - deviceGroupId, + vulnerabilityId: decision.targetId, confidence, reasonWhy: decision.reasonWhy, }, update: { confidence, reasonWhy: decision.reasonWhy }, }); - - // link the device group to the notification - if (decision.op === "link") { - if (!decision.targetId || !validIds.has(decision.targetId)) { - summary.skipped++; - continue; + const data = cleanFields(decision.fields); + if (decision.op === "update" && data.description) { + await tx.vulnerability.update({ + where: { id: decision.targetId }, + data: { description: data.description }, + }); } - await upsertMapping(decision.targetId); - summary.linked++; - } - // update the device group and link it to the notification - // TODO: consider, we may want to separate this into 'update' and 'update_and_link' actions - else if (decision.op === "update") { - if (!decision.targetId || !validIds.has(decision.targetId)) { + const cvssScore = data.cvssScore; + if (cvssScore !== null || data.cvssVector) { + await enrichVulnerabilityCvss(decision.targetId, { + cvssScore, + cvssVector: data.cvssVector, + }); + } + if (decision.op === "update") summary.updated++; + else summary.linked++; + } else if (decision.kind === "remediation") { + if ( + !decision.targetId || + !validIds.remediation.has(decision.targetId) + ) { summary.skipped++; continue; } - // Vendor/product/version are part of a device group's identity now and - // can't be mutated in place; the only safe enrichment is unioning a new - // CPE into the existing group's cpe[] array. - const data = cleanFields(decision.fields); - if (data.cpe) { - const group = await tx.deviceGroup.findUnique({ - where: { id: decision.targetId }, - select: { cpe: true }, - }); - if (group && !group.cpe.includes(data.cpe)) { - await tx.deviceGroup.update({ + await tx.notificationRemediationMapping.upsert({ + where: { + notificationId_remediationId: { + notificationId, + remediationId: decision.targetId, + }, + }, + create: { + notificationId, + remediationId: decision.targetId, + confidence, + reasonWhy: decision.reasonWhy, + }, + update: { confidence, reasonWhy: decision.reasonWhy }, + }); + if (decision.op === "update") { + const data = cleanFields(decision.fields); + if (data.description) { + await tx.remediation.update({ where: { id: decision.targetId }, - data: { cpe: { push: data.cpe } }, + data: { description: data.description }, }); } + summary.updated++; + } else { + summary.linked++; } - await upsertMapping(decision.targetId); - summary.updated++; - } else { - // create - const data = cleanFields(decision.fields); - const cpe = data.cpe; - if (!cpe) { + } else if (decision.kind === "asset") { + if (!decision.targetId || !validIds.asset.has(decision.targetId)) { summary.skipped++; continue; } - // The CPE is required and authoritative: cpeToDeviceGroup parses it into - // canonical vendor/product/version, sets versionStatus, attaches the CPE - // to cpe[], and find-or-creates by identity (race-safe, idempotent). - const created = await cpeToDeviceGroup(cpe); - await upsertMapping(created.id); - summary.created++; + await tx.notificationAssetMapping.upsert({ + where: { + notificationId_assetId: { + notificationId, + assetId: decision.targetId, + }, + }, + create: { + notificationId, + assetId: decision.targetId, + confidence, + reasonWhy: decision.reasonWhy, + }, + update: { confidence, reasonWhy: decision.reasonWhy }, + }); + const data = cleanFields(decision.fields); + if (data.macAddress || data.serialNumber) { + await enrichAssetIdentifiers(decision.targetId, { + macAddress: data.macAddress, + serialNumber: data.serialNumber, + }); + } + summary.linked++; + } else { + summary.skipped++; } } }); diff --git a/src/features/inbox/components/columns.tsx b/src/features/inbox/components/columns.tsx index a0a47f5c..8e3d1220 100644 --- a/src/features/inbox/components/columns.tsx +++ b/src/features/inbox/components/columns.tsx @@ -12,7 +12,7 @@ import { HoverCardTrigger, } from "@/components/ui/hover-card"; import { Pill } from "@/components/ui/pill"; -import { deviceGroupLabel } from "@/lib/string-utils"; +import { deviceGroupMatchingLabel } from "@/lib/string-utils"; import type { NotificationWithRelations, RawEmailPayload } from "../types"; import { NotificationTypeBadge } from "./notification-type-badge"; @@ -120,15 +120,15 @@ export const notificationColumns: ColumnDef[] = [ meta: { title: "Assets" }, header: "Assets", cell: ({ row }) => { - const { deviceGroups } = row.original; - if (deviceGroups.length === 0) { + const { deviceGroupsMatchings } = row.original; + if (deviceGroupsMatchings.length === 0) { return —; } return (
- {deviceGroups.map((mapping) => { - const label = deviceGroupLabel(mapping.deviceGroup); - const count = mapping.deviceGroup._count.assets; + {deviceGroupsMatchings.map((mapping) => { + const label = deviceGroupMatchingLabel(mapping.deviceGroupMatching); + const count = mapping.assetCount; return ( {label} diff --git a/src/features/inbox/components/notification-affected-assets-tab.tsx b/src/features/inbox/components/notification-affected-assets-tab.tsx index 7360ba48..b19bae76 100644 --- a/src/features/inbox/components/notification-affected-assets-tab.tsx +++ b/src/features/inbox/components/notification-affected-assets-tab.tsx @@ -11,17 +11,15 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { deviceGroupLabel, parseLocation } from "@/lib/string-utils"; +import { deviceGroupMatchingLabel, parseLocation } from "@/lib/string-utils"; import type { NotificationDetailWithRelations } from "../types"; export function NotificationAffectedAssetsTab({ - deviceGroups, + deviceGroupsMatchings, }: { - deviceGroups: NotificationDetailWithRelations["deviceGroups"]; + deviceGroupsMatchings: NotificationDetailWithRelations["deviceGroupsMatchings"]; }) { - const withAssets = deviceGroups.filter( - (m) => m.deviceGroup._count.assets > 0, - ); + const withAssets = deviceGroupsMatchings.filter((m) => m.assetCount > 0); if (withAssets.length === 0) { return ( @@ -37,9 +35,9 @@ export function NotificationAffectedAssetsTab({ - {deviceGroupLabel(mapping.deviceGroup)} + {deviceGroupMatchingLabel(mapping.deviceGroupMatching)} - {mapping.deviceGroup._count.assets} + {mapping.assetCount} @@ -55,7 +53,7 @@ export function NotificationAffectedAssetsTab({ - {mapping.deviceGroup.assets.map((asset) => ( + {mapping.assets.map((asset) => ( {asset.hostname ?? asset.id} diff --git a/src/features/inbox/components/notification-detail.tsx b/src/features/inbox/components/notification-detail.tsx index 11579462..10ef7322 100644 --- a/src/features/inbox/components/notification-detail.tsx +++ b/src/features/inbox/components/notification-detail.tsx @@ -73,11 +73,10 @@ export const NotificationDetailPage = ({ id }: { id: string }) => { const displayTitle = notification.title ?? notification.summary ?? notification.id; - const totalDeviceGroups = notification.deviceGroups.length; - const deviceGroupsWithAssets = notification.deviceGroups.filter( - (m) => m.deviceGroup._count.assets > 0, + const totalDeviceGroups = notification.deviceGroupsMatchings.length; + const deviceGroupsWithAssets = notification.deviceGroupsMatchings.filter( + (m) => m.assetCount > 0, ).length; - const firstReceived = notification.sources.length > 0 ? new Date( @@ -173,7 +172,7 @@ export const NotificationDetailPage = ({ id }: { id: string }) => { className="flex flex-col gap-4 mt-4" > diff --git a/src/features/inbox/server/routers.ts b/src/features/inbox/server/routers.ts index 30fd2859..2e9badd4 100644 --- a/src/features/inbox/server/routers.ts +++ b/src/features/inbox/server/routers.ts @@ -2,13 +2,24 @@ import "server-only"; import { z } from "zod"; import { NotificationType, Priority } from "@/generated/prisma"; import prisma from "@/lib/db"; +import { + deviceGroupWhereForMatching, + matchingAppliesToDeviceGroup, +} from "@/lib/device-matching"; import { buildPaginationMeta, createPaginatedResponse, paginationInputSchema, } from "@/lib/pagination"; import { createTRPCRouter, protectedProcedure } from "@/trpc/init"; -import { notificationDetailInclude } from "../types"; +import { notificationDetailInclude, notificationInclude } from "../types"; + +type MatchingIdentity = { + vendorId: string; + productId: string | null; + versionId: string | null; + versionRange: string | null; +}; const ALLOWED_SORT_FIELDS = new Set(["priority", "updatedAt", "createdAt"]); @@ -16,23 +27,6 @@ function getSortValue(segment: string): "asc" | "desc" { return segment.startsWith("-") ? "desc" : "asc"; } -const notificationInclude = { - deviceGroups: { - include: { - deviceGroup: { - include: { - vendor: true, - product: true, - _count: { select: { assets: true } }, - }, - }, - }, - }, - sources: { - select: { id: true, channel: true, raw: true, receivedAt: true }, - }, -} as const; - const createSearchFilter = (search: string) => { if (!search) return {}; const insensitive = { contains: search, mode: "insensitive" as const }; @@ -41,6 +35,50 @@ const createSearchFilter = (search: string) => { }; }; +async function resolvedDeviceGroupAssetCount( + matching: MatchingIdentity, +): Promise { + const candidates = await prisma.deviceGroup.findMany({ + where: deviceGroupWhereForMatching(matching), + select: { + id: true, + vendorId: true, + productId: true, + versionId: true, + version: { select: { canonicalName: true } }, + _count: { select: { assets: true } }, + }, + }); + return candidates + .filter((dg) => matchingAppliesToDeviceGroup(matching, dg)) + .reduce((sum, dg) => sum + dg._count.assets, 0); +} + +async function resolveDeviceGroupAssets(matching: MatchingIdentity) { + const candidates = await prisma.deviceGroup.findMany({ + where: deviceGroupWhereForMatching(matching), + select: { + id: true, + vendorId: true, + productId: true, + versionId: true, + version: { select: { canonicalName: true } }, + assets: { + select: { + id: true, + ip: true, + hostname: true, + location: true, + status: true, + }, + }, + }, + }); + return candidates + .filter((dg) => matchingAppliesToDeviceGroup(matching, dg)) + .flatMap((dg) => dg.assets); +} + export const notificationsRouter = createTRPCRouter({ getMany: protectedProcedure .input( @@ -88,7 +126,21 @@ export const notificationsRouter = createTRPCRouter({ : { updatedAt: "desc" }, }); - return createPaginatedResponse(notifications, meta); + const items = await Promise.all( + notifications.map(async (n) => ({ + ...n, + deviceGroupsMatchings: await Promise.all( + n.deviceGroupsMatchings.map(async (m) => ({ + ...m, + assetCount: await resolvedDeviceGroupAssetCount( + m.deviceGroupMatching, + ), + })), + ), + })), + ); + + return createPaginatedResponse(items, meta); }), getOne: protectedProcedure @@ -108,8 +160,13 @@ export const notificationsRouter = createTRPCRouter({ if (!notification) { throw new Error("Notification not found"); } - - return notification; + const deviceGroupsMatchings = await Promise.all( + notification.deviceGroupsMatchings.map(async (m) => { + const assets = await resolveDeviceGroupAssets(m.deviceGroupMatching); + return { ...m, assetCount: assets.length, assets }; + }), + ); + return { ...notification, deviceGroupsMatchings }; }), markRead: protectedProcedure diff --git a/src/features/inbox/types.ts b/src/features/inbox/types.ts index c3381f56..41ede385 100644 --- a/src/features/inbox/types.ts +++ b/src/features/inbox/types.ts @@ -1,14 +1,14 @@ import { z } from "zod"; -import type { Prisma } from "@/generated/prisma"; +import type { AssetStatus, Prisma } from "@/generated/prisma"; export const notificationInclude = { - deviceGroups: { + deviceGroupsMatchings: { include: { - deviceGroup: { + deviceGroupMatching: { include: { vendor: true, product: true, - _count: { select: { assets: true } }, + version: true, }, }, }, @@ -21,29 +21,29 @@ export const notificationInclude = { }, } satisfies Prisma.NotificationInclude; -export type NotificationWithRelations = Prisma.NotificationGetPayload<{ +type NotificationBasePayload = Prisma.NotificationGetPayload<{ include: typeof notificationInclude; }>; +export type NotificationWithRelations = Omit< + NotificationBasePayload, + "deviceGroupsMatchings" +> & { + deviceGroupsMatchings: (NotificationBasePayload["deviceGroupsMatchings"][number] & { + assetCount: number; + })[]; +}; + export type NotificationSource = NotificationWithRelations["sources"][number]; export const notificationDetailInclude = { - deviceGroups: { + deviceGroupsMatchings: { include: { - deviceGroup: { + deviceGroupMatching: { include: { vendor: true, product: true, - _count: { select: { assets: true } }, - assets: { - select: { - id: true, - ip: true, - hostname: true, - location: true, - status: true, - }, - }, + version: true, }, }, }, @@ -63,10 +63,28 @@ export const notificationDetailInclude = { }, } satisfies Prisma.NotificationInclude; -export type NotificationDetailWithRelations = Prisma.NotificationGetPayload<{ +type NotificationDetailBasePayload = Prisma.NotificationGetPayload<{ include: typeof notificationDetailInclude; }>; +export type ResolvedDeviceGroupAsset = { + id: string; + ip: string; + hostname: string | null; + location: unknown; + status: AssetStatus | null; +}; + +export type NotificationDetailWithRelations = Omit< + NotificationDetailBasePayload, + "deviceGroupsMatchings" +> & { + deviceGroupsMatchings: (NotificationDetailBasePayload["deviceGroupsMatchings"][number] & { + assetCount: number; + assets: ResolvedDeviceGroupAsset[]; + })[]; +}; + export type NotificationDetailSource = NotificationDetailWithRelations["sources"][number]; diff --git a/src/features/inbox/utils.ts b/src/features/inbox/utils.ts index cfce61cb..450672fe 100644 --- a/src/features/inbox/utils.ts +++ b/src/features/inbox/utils.ts @@ -1,5 +1,6 @@ import "server-only"; import prisma from "@/lib/db"; +import { normalizeName } from "@/lib/router-utils"; import { downloadBufferFromS3, keyFromDownloadUrl } from "@/lib/s3"; /** @@ -40,3 +41,133 @@ export async function fetchPdfAttachments( return results.filter((a) => a !== null); } + +// Similiar to resolveDeviceGroup except it doesn't create. From +// Notification mentioning an identifier isn't evidence the hospital owns the device +// creating one from notification would pollute inventory +export async function enrichDeviceGroupIdentifiers( + identity: { + vendorId: string; + productId: string | null; + versionId: string | null; + }, + updates: { cpe?: string | null; udi?: string | null }, +): Promise { + const deviceGroup = await prisma.deviceGroup.findFirst({ + where: { + vendorId: identity.vendorId, + productId: identity.productId, + versionId: identity.versionId, + }, + }); + if (!deviceGroup) return; + + const mergedCpes = updates.cpe + ? [...new Set([...deviceGroup.cpe, updates.cpe])] + : deviceGroup.cpe; + const needsCpe = mergedCpes.length !== deviceGroup.cpe.length; + const needsUdi = !!updates.udi && !deviceGroup.udi; + if (!needsCpe && !needsUdi) return; + + await prisma.deviceGroup.update({ + where: { id: deviceGroup.id }, + data: { + ...(needsCpe ? { cpe: mergedCpes } : {}), + ...(needsUdi ? { udi: updates.udi } : {}), + }, + }); +} + +export async function enrichVulnerabilityCvss( + vulnerabilityId: string, + updates: { + cvssScore?: number | null; + cvssVector?: string | null; + }, +): Promise { + const vulnerability = await prisma.vulnerability.findUnique({ + where: { id: vulnerabilityId }, + select: { cvssScore: true, cvssVector: true }, + }); + + if (!vulnerability) return; + + const needsScore = + updates.cvssScore !== null && vulnerability.cvssScore === null; + const needsVector = !!updates.cvssVector && !vulnerability.cvssVector; + + if (!needsScore && !needsVector) return; + + await prisma.vulnerability.update({ + where: { id: vulnerabilityId }, + data: { + ...(needsScore ? { cvssScore: updates.cvssScore } : {}), + ...(needsVector ? { cvssVector: updates.cvssVector } : {}), + }, + }); +} + +export async function enrichAssetIdentifiers( + assetId: string, + updates: { macAddress?: string | null; serialNumber?: string | null }, +): Promise { + const asset = await prisma.asset.findUnique({ + where: { id: assetId }, + select: { macAddress: true, serialNumber: true }, + }); + if (!asset) return; + + const needsMac = !!updates.macAddress && !asset.macAddress; + const needsSerial = !!updates.serialNumber && !asset.serialNumber; + if (!needsMac && !needsSerial) return; + await prisma.asset.update({ + where: { id: assetId }, + data: { + ...(needsMac ? { macAddress: updates.macAddress } : {}), + ...(needsSerial ? { serialNumber: updates.serialNumber } : {}), + }, + }); +} + +export async function addVendorAlias(id: string, alias: string): Promise { + const normalized = normalizeName(alias); + const row = await prisma.vendor.findUnique({ + where: { id }, + select: { canonicalName: true, nameMappings: true }, + }); + + if (!row) return; + if ( + normalized === row.canonicalName || + row.nameMappings.includes(normalized) + ) { + return; + } + await prisma.vendor.update({ + where: { id }, + data: { nameMappings: { push: normalized } }, + }); +} + +export async function addProductAlias( + id: string, + alias: string, +): Promise { + const normalized = normalizeName(alias); + const row = await prisma.product.findUnique({ + where: { id }, + select: { canonicalName: true, nameMappings: true }, + }); + + if (!row) return; + if ( + normalized === row.canonicalName || + row.nameMappings.includes(normalized) + ) { + return; + } + await prisma.product.update({ + where: { id }, + data: { nameMappings: { push: normalized } }, + }); +} diff --git a/src/lib/router-utils.ts b/src/lib/router-utils.ts index ab690c2b..32ac189c 100644 --- a/src/lib/router-utils.ts +++ b/src/lib/router-utils.ts @@ -88,7 +88,7 @@ interface PrismaClientLike { // ============================================================================ // Normalize a name for canonical lookup (the displayName keeps the original). -function normalizeName(name: string): string { +export function normalizeName(name: string): string { return name.trim().toLowerCase(); } @@ -266,7 +266,6 @@ export async function resolveDeviceGroup(identity: DeviceGroupIdentityInput) { if (!deviceGroup) throw error; } } - // Union any new CPEs / fill in a missing UDI on the existing/created group. const mergedCpes = [...new Set([...deviceGroup.cpe, ...cpes])]; const needsCpe = mergedCpes.length !== deviceGroup.cpe.length; @@ -355,6 +354,7 @@ type MatchingResolveInput = { product?: string | null; version?: string | null; versionRange?: string | null; + hasCpe?: boolean; }; /** @@ -362,13 +362,16 @@ type MatchingResolveInput = { * identity (resolved to canonical rows). Identities come from CPEs, so * canonicals are marked CPE-backed. */ -async function resolveMatchingId(input: MatchingResolveInput): Promise { - const vendorRow = await resolveVendor(input.vendor, { hasCpe: true }); +export async function resolveMatchingId( + input: MatchingResolveInput, +): Promise { + const hasCpe = input.hasCpe ?? true; + const vendorRow = await resolveVendor(input.vendor, { hasCpe }); const productRow = input.product - ? await resolveProduct(input.product, { hasCpe: true }) + ? await resolveProduct(input.product, { hasCpe }) : null; const versionRow = input.version - ? await resolveVersion(input.version, { hasCpe: true }) + ? await resolveVersion(input.version, { hasCpe }) : null; const where = {