From 77e6df35d8613982f545098f5bc835c96a58373f Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Mon, 29 Jun 2026 12:45:45 -0400 Subject: [PATCH 01/15] update Notification schema, link to DeviceGroupMapping instead of DeviceGroup --- .../migration.sql | 23 +++++++++++++++++++ prisma/schema.prisma | 13 ++++++----- 2 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260629161924_notification_device_group_matching/migration.sql 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/schema.prisma b/prisma/schema.prisma index 1107a4fa..34d1d7b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -273,7 +273,7 @@ model DeviceGroup { // Vulnerabilities / remediations / advisories / device artifacts connect via // DeviceGroupMatching now, not a direct relation. notificationMappings is from // main's notifications feature and stays. - notificationMappings NotificationDeviceGroupMapping[] + // 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 @@ -300,6 +300,7 @@ model DeviceGroupMatching { remediations Remediation[] @relation("RemediationMatchings") advisories Advisory[] @relation("AdvisoryMatchings") deviceArtifacts DeviceArtifact[] @relation("DeviceArtifactMatchings") + notificationMappings NotificationDeviceGroupMapping[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -966,8 +967,8 @@ model Notification { sources NotificationSource[] reads NotificationRead[] - assets NotificationAssetMapping[] - deviceGroups NotificationDeviceGroupMapping[] + assets NotificationAssetMapping[] + deviceGroupsMatchings NotificationDeviceGroupMapping[] vulnerabilities NotificationVulnerabilityMapping[] remediations NotificationRemediationMapping[] @@ -1047,12 +1048,12 @@ model NotificationDeviceGroupMapping { id String @id @default(cuid()) notificationId String notification Notification @relation(fields: [notificationId], 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([notificationId, deviceGroupMatchingId]) @@map("notification_device_group_mapping") } From 060409c7b921fa846b6ef672a9bb2a7a9e7692e5 Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Tue, 30 Jun 2026 15:23:00 -0400 Subject: [PATCH 02/15] update usage from deviceGroup linking with deviceGroupMatching linking --- src/features/inbox/agent/candidate-search.ts | 38 +++++----- src/features/inbox/agent/extract.ts | 2 + src/features/inbox/agent/match.ts | 76 +++++++++++++------- src/lib/router-utils.ts | 12 ++-- 4 files changed, 81 insertions(+), 47 deletions(-) diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index 183fd0fb..b58fb060 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -4,38 +4,47 @@ 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" 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 Candidates = { // Parallel to ExtractResult: one candidate list per extracted entity, in order. deviceGroups: Array<{ extracted: ExtractedDeviceGroup; - matches: DeviceGroupCandidate[]; + matches: DeviceGroupMatchingCandidate[]; }>; }; +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 { +): Promise { const terms = [ - extracted.cpe, extracted.manufacturer, extracted.modelName, ].filter((t): t is string => !!t && t.trim().length > 0); if (terms.length === 0) return []; - const or: Prisma.DeviceGroupWhereInput[] = []; + 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) { @@ -43,32 +52,29 @@ async function searchDeviceGroup( // 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, }); 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, + versionRange: row.versionRange })); } @@ -78,7 +84,7 @@ export async function searchCandidates( const deviceGroups = await Promise.all( extracted.deviceGroups.map(async (dg) => ({ extracted: dg, - matches: await searchDeviceGroup(dg), + matches: await searchDeviceGroupMatching(dg), })), ); diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index 074d466d..f3795f5e 100644 --- a/src/features/inbox/agent/extract.ts +++ b/src/features/inbox/agent/extract.ts @@ -21,6 +21,7 @@ export const extractedDeviceGroupSchema = z.object({ manufacturer: z.string().nullish(), modelName: z.string().nullish(), version: z.string().nullish(), + versionRange: z.string().nullish(), }); // TODO: extend this, which has just device groups for now, with vulnerabilities @@ -41,6 +42,7 @@ Your task: extract the DEVICE GROUPS that the notification is about. A device gr - 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. RULES: - Extract ONLY device groups explicitly referenced in the notification or its attachments. diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 41437921..9d9b3d96 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -7,7 +7,7 @@ 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 type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -20,10 +20,11 @@ const deviceGroupFieldsSchema = z.object({ manufacturer: z.string().nullish(), modelName: z.string().nullish(), version: z.string().nullish(), + versionRange: z.string().nullish() }); const decisionSchema = z.object({ - kind: z.enum(["deviceGroup"]), // TODO: add vulnerability, remediation, maybe asset... + kind: z.enum(["deviceGroupMatching"]), // TODO: add vulnerability, remediation, maybe asset... op: z.enum(["link", "update", "create"]), // The id of an existing candidate to link/update. Omitted for create. targetId: z.string().nullish(), @@ -52,8 +53,8 @@ You are given, for each device group extracted from the notification, a list of 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. CONFIDENCE (you may only use these two): - "Matched": strong identifier match (same CPE, or same manufacturer + model). @@ -68,13 +69,13 @@ function renderCandidates(candidates: Candidates): string { return candidates.deviceGroups .map((entry, i) => { const e = entry.extracted; - const extractedLine = `Device group #${i + 1} extracted: cpe=${e.cpe ?? "?"} | manufacturer=${e.manufacturer ?? "?"} | modelName=${e.modelName ?? "?"} | version=${e.version ?? "?"}`; + const extractedLine = `Device group #${i + 1} extracted: 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} | cpe: ${m.cpe.join(", ") || "(none)"} | manufacturer: ${m.manufacturer ?? "(none)"} | modelName: ${m.modelName ?? "(none)"} | version: ${m.version ?? "(none)"}`, + ` - id: ${m.id} | manufacturer: ${m.manufacturer ?? "(none)"} | modelName: ${m.modelName ?? "(none)"} | version: ${m.version ?? "(none)"}`, ) .join("\n") : " - (no candidates found)"; @@ -85,14 +86,14 @@ function renderCandidates(candidates: Candidates): string { // 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 } { 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"] as const) { const val = fields[key]; if (typeof val === "string" && val.trim().length > 0) out[key] = val.trim(); } @@ -149,7 +150,7 @@ export async function applyDecisions( await prisma.$transaction(async (tx) => { for (const decision of decisions) { - if (decision.kind !== "deviceGroup") { + if (decision.kind !== "deviceGroupMatching") { summary.skipped++; continue; } @@ -158,14 +159,14 @@ export async function applyDecisions( const confidence: ConfidenceLevel = decision.confidence === "Matched" ? "Matched" : "NeedsReview"; - const upsertMapping = (deviceGroupId: string) => + const upsertMapping = (deviceGroupMatchingId: string) => tx.notificationDeviceGroupMapping.upsert({ where: { - notificationId_deviceGroupId: { notificationId, deviceGroupId }, + notificationId_deviceGroupMatchingId: { notificationId, deviceGroupMatchingId }, }, create: { notificationId, - deviceGroupId, + deviceGroupMatchingId, confidence, reasonWhy: decision.reasonWhy, }, @@ -192,33 +193,56 @@ export async function applyDecisions( // 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({ + if(data.versionRange){ + const existing = await tx.deviceGroupMatching.findUnique({ where: { id: decision.targetId }, - select: { cpe: true }, + select: { versionRange: true } }); - if (group && !group.cpe.includes(data.cpe)) { - await tx.deviceGroup.update({ + if( existing && !existing.versionRange) { + await tx.deviceGroupMatching.update({ where: { id: decision.targetId }, - data: { cpe: { push: data.cpe } }, - }); + data: { versionRange: data.versionRange} + }) } } + // 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({ + // where: { id: decision.targetId }, + // data: { cpe: { push: data.cpe } }, + // }); + // } + // } await upsertMapping(decision.targetId); summary.updated++; } else { // create const data = cleanFields(decision.fields); - const cpe = data.cpe; - if (!cpe) { + // const cpe = data.cpe; + // if (!cpe) { + // 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); + if(!data.manufacturer){ 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); + const matchingId = await resolveMatchingId({ + vendor: data.manufacturer, + product: data.modelName, + version: data.version, + versionRange: data.versionRange, + hasCpe: false + }) + await upsertMapping(matchingId); summary.created++; } } diff --git a/src/lib/router-utils.ts b/src/lib/router-utils.ts index 06075ef0..83e6810e 100644 --- a/src/lib/router-utils.ts +++ b/src/lib/router-utils.ts @@ -55,7 +55,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(); } @@ -322,6 +322,7 @@ type MatchingResolveInput = { product?: string | null; version?: string | null; versionRange?: string | null; + hasCpe?: boolean; }; /** @@ -329,13 +330,14 @@ 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 = { From 59dfab471395c7975e0f874b86cd8be9ecb5b7c1 Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Tue, 30 Jun 2026 16:52:07 -0400 Subject: [PATCH 03/15] add match with vulnerability handling --- src/features/inbox/agent/candidate-search.ts | 64 +++++- src/features/inbox/agent/extract.ts | 16 +- src/features/inbox/agent/match.ts | 203 +++++++++++-------- 3 files changed, 194 insertions(+), 89 deletions(-) diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index b58fb060..643b8d74 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -3,7 +3,7 @@ import "server-only"; import type { Prisma } from "@/generated/prisma"; import prisma from "@/lib/db"; -import type { ExtractedDeviceGroup, ExtractResult } from "./extract"; +import type { ExtractedDeviceGroup, ExtractedVulnerability, ExtractResult } from "./extract"; import { normalizeName } from "@/lib/router-utils" const TOP_K = 5; @@ -16,12 +16,22 @@ export type DeviceGroupMatchingCandidate = { versionRange: string | null; }; +export type VulnerabilitiyCandidate = { + id: string; + cveId: string | null; + description: string | null +} + export type Candidates = { // Parallel to ExtractResult: one candidate list per extracted entity, in order. deviceGroups: Array<{ extracted: ExtractedDeviceGroup; matches: DeviceGroupMatchingCandidate[]; }>; + vulnerabilities: Array<{ + extracted: ExtractedVulnerability; + matches: VulnerabilitiyCandidate[] + }> }; const nameOrClauses = (term: string) => [ @@ -78,15 +88,55 @@ async function searchDeviceGroupMatching( })); } +async function searchVulnerability(extracted: ExtractedVulnerability): Promise { + const or: Prisma.VulnerabilityWhereInput[] = []; + if(extracted.cveId) { + or.push({ cveId: { contains: extracted.cveId, mode: "insensitive"}}) + } + if(extracted.manufacturer) { + or.push({ + deviceGroupMatchings: { + some: {vendor: { OR: vendorNameOr(extracted.manufacturer)}} + } + }) + } + if(extracted.modelName) { + or.push({ + deviceGroupMatchings: { + some: { product: { OR: productNameOr(extracted.modelName)}} + } + }) + } + if(or.length === 0) return []; + + const rows = await prisma.vulnerability.findMany({ + where: { OR: or }, + select: { id: true, cveId: true, description: true }, + take: TOP_K + }); + + return rows.map((row) => ({ + id: row.id, + cveId: row.cveId ?? null, + description: row.description ?? null + })); +} + export async function searchCandidates( extracted: ExtractResult, ): Promise { - const deviceGroups = await Promise.all( - extracted.deviceGroups.map(async (dg) => ({ - extracted: dg, - matches: await searchDeviceGroupMatching(dg), - })), + const [deviceGroups, vulnerabilities] = 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) + }))) + ] ); - return { deviceGroups }; + return { deviceGroups, vulnerabilities }; } diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index f3795f5e..03d46e53 100644 --- a/src/features/inbox/agent/extract.ts +++ b/src/features/inbox/agent/extract.ts @@ -24,26 +24,40 @@ export const extractedDeviceGroupSchema = z.object({ versionRange: z.string().nullish(), }); +export const extractedVulnerabilitySchema = z.object({ + cveId: z.string().regex(/^CVE-\d{4}-\d{4,}$/i).nullish(), + manufacturer: z.string().nullish(), + modelName: 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) }); export type ExtractedDeviceGroup = z.infer; +export type ExtractedVulnerability = z.infer; export type ExtractResult = z.infer; const MODEL = "claude-haiku-4-5-20251001"; 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 DEVICE GROUPS and VULNERABILITIES that the notification is about. A device group is a class of affected product, identified by some combination of: + +For DEVICE GROUPS: - CPE string (e.g. "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*") - 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. +- manufacturer / modelName of the affected device if mentioned alongside the CVE + RULES: - Extract ONLY device groups explicitly referenced in the notification or its attachments. - Omit any field you are unsure about; do not guess or fabricate CPEs, models, or versions. diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 9d9b3d96..1ffa16f4 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -24,7 +24,7 @@ const deviceGroupFieldsSchema = z.object({ }); const decisionSchema = z.object({ - kind: z.enum(["deviceGroupMatching"]), // TODO: add vulnerability, remediation, maybe asset... + kind: z.enum(["deviceGroupMatching", "vulnerability"]), // TODO: add vulnerability, remediation, maybe asset... op: z.enum(["link", "update", "create"]), // The id of an existing candidate to link/update. Omitted for create. targetId: z.string().nullish(), @@ -47,27 +47,35 @@ 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 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. Set targetId to that candidate's id. +For + CONFIDENCE (you may only use these two): -- "Matched": strong identifier match (same CPE, or same manufacturer + model). +- "Matched": strong identifier match (same CVE id, 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 { + const sections: string[] = []; + if (candidates.deviceGroups.length === 0) return "(no device groups extracted)"; - return candidates.deviceGroups - .map((entry, i) => { + 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 = @@ -81,7 +89,27 @@ function renderCandidates(candidates: Candidates): string { : " - (no candidates found)"; return `${extractedLine}\n Candidates:\n${matches}`; }) - .join("\n\n"); + .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 ?? "?"} | | manufacturer=${e.manufacturer ?? "?"} | modelName=${e.modelName ?? "?"}`; + const matches = entry.matches.length > 0 + ? entry.matches + .map((m) => ` - id: ${m.id} | cveId: ${m.cveId ?? "(none)"} | description: ${m.description} ?? "(none)"}`).join("\n") + : " - (no candidates found)"; + + return `${line}\n Candidates: \m${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. @@ -144,22 +172,19 @@ 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 = new Set([ + ...candidates.deviceGroups.flatMap((e) => e.matches.map((m) => m.id)), + ...candidates.vulnerabilities.flatMap((e) => e.matches.map((m) => m.id)), + ]); await prisma.$transaction(async (tx) => { for (const decision of decisions) { - if (decision.kind !== "deviceGroupMatching") { - summary.skipped++; - continue; - } - // Hard guard: the pipeline never writes Confirmed. const confidence: ConfidenceLevel = decision.confidence === "Matched" ? "Matched" : "NeedsReview"; - const upsertMapping = (deviceGroupMatchingId: string) => + if(decision.kind === "deviceGroupMatching"){ + const upsertMapping = (deviceGroupMatchingId: string) => tx.notificationDeviceGroupMapping.upsert({ where: { notificationId_deviceGroupMatchingId: { notificationId, deviceGroupMatchingId }, @@ -173,77 +198,93 @@ export async function applyDecisions( 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; - } - 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)) { - summary.skipped++; - continue; + // link the device group to the notification + if (decision.op === "link") { + if (!decision.targetId || !validIds.has(decision.targetId)) { + summary.skipped++; + continue; + } + await upsertMapping(decision.targetId); + summary.linked++; } - // 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.versionRange){ - const existing = await tx.deviceGroupMatching.findUnique({ - where: { id: decision.targetId }, - select: { versionRange: true } - }); - if( existing && !existing.versionRange) { - await tx.deviceGroupMatching.update({ + // 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)) { + 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.versionRange){ + const existing = await tx.deviceGroupMatching.findUnique({ where: { id: decision.targetId }, - data: { versionRange: data.versionRange} - }) + select: { versionRange: true } + }); + if( existing && !existing.versionRange) { + await tx.deviceGroupMatching.update({ + where: { id: decision.targetId }, + data: { versionRange: data.versionRange} + }) + } + } + // 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({ + // where: { id: decision.targetId }, + // data: { cpe: { push: data.cpe } }, + // }); + // } + // } + await upsertMapping(decision.targetId); + summary.updated++; + } else { + // create + const data = cleanFields(decision.fields); + // const cpe = data.cpe; + // if (!cpe) { + // 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); + 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); + summary.created++; } - // 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({ - // where: { id: decision.targetId }, - // data: { cpe: { push: data.cpe } }, - // }); - // } - // } - await upsertMapping(decision.targetId); - summary.updated++; - } else { - // create - const data = cleanFields(decision.fields); - // const cpe = data.cpe; - // if (!cpe) { - // 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); - if(!data.manufacturer){ + } else if (decision.kind === "vulnerability") { + if(!decision.targetId || !validIds.has(decision.targetId)) { summary.skipped++; continue; } - const matchingId = await resolveMatchingId({ - vendor: data.manufacturer, - product: data.modelName, - version: data.version, - versionRange: data.versionRange, - hasCpe: false - }) - await upsertMapping(matchingId); - summary.created++; + await tx.notificationVulnerabilityMapping.upsert({ + where: { + notificationId_vulnerabilityId: { notificationId, vulnerabilityId: decision.targetId} + }, + create: { notificationId, vulnerabilityId: decision.targetId, confidence, reasonWhy: decision.reasonWhy}, + update: { confidence, reasonWhy: decision.reasonWhy} + }); + summary.linked++; + } else { + summary.skipped++; } } }); From 9a1cbd9402c1d30e6134eb2e552ababed3ed82a8 Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Wed, 1 Jul 2026 21:13:40 -0400 Subject: [PATCH 04/15] add searchRemediation, searchAsset, addressed initial comments --- src/features/inbox/agent/candidate-search.ts | 193 ++++++++++++++++--- src/features/inbox/agent/extract.ts | 43 +++-- src/features/inbox/agent/match.ts | 161 +++++++++++----- src/lib/router-utils.ts | 66 ++++++- 4 files changed, 377 insertions(+), 86 deletions(-) diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index 643b8d74..25467517 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -3,8 +3,9 @@ import "server-only"; import type { Prisma } from "@/generated/prisma"; import prisma from "@/lib/db"; -import type { ExtractedDeviceGroup, ExtractedVulnerability, ExtractResult } from "./extract"; +import type { ExtractedAsset, ExtractedDeviceGroup, ExtractedRemediation, ExtractedVulnerability, ExtractResult } from "./extract"; import { normalizeName } from "@/lib/router-utils" +import { PrimitiveAtom } from "jotai"; const TOP_K = 5; @@ -20,7 +21,19 @@ export type VulnerabilitiyCandidate = { id: string; cveId: string | null; description: string | null -} +}; + +export type RemediationCandidate = { + id: string; + linkedCveId: string | null; + description: string | null; +}; + +export type AssetCandidate = { + id: string; + ip: string | null; + hostname: string | null; +}; export type Candidates = { // Parallel to ExtractResult: one candidate list per extracted entity, in order. @@ -31,6 +44,14 @@ export type Candidates = { vulnerabilities: Array<{ extracted: ExtractedVulnerability; matches: VulnerabilitiyCandidate[] + }>; + remediations: Array<{ + extracted: ExtractedRemediation; + matches: RemediationCandidate[] + }>, + assets: Array<{ + extracted: ExtractedAsset; + matches: AssetCandidate[] }> }; @@ -47,6 +68,59 @@ const productNameOr = (term: string):Prisma.ProductWhereInput[] => nameOrClauses async function searchDeviceGroupMatching( extracted: ExtractedDeviceGroup, ): 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(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 identity = { + vendorId: deviceGroup.vendorId, + prodcutId: 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, @@ -58,7 +132,6 @@ async function searchDeviceGroupMatching( // 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( @@ -79,13 +152,17 @@ async function searchDeviceGroupMatching( take: TOP_K, }); - return rows.map((row) => ({ - id: row.id, - manufacturer: row.vendor?.canonicalDisplayName ?? null, - modelName: row.product?.canonicalDisplayName ?? null, - version: row.version?.canonicalDisplayName ?? null, - versionRange: row.versionRange - })); + + 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 { @@ -93,20 +170,7 @@ async function searchVulnerability(extracted: ExtractedVulnerability): 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, + 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}}) + } + 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, + 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 + })); +} + export async function searchCandidates( extracted: ExtractResult, ): Promise { - const [deviceGroups, vulnerabilities] = await Promise.all( + 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)=> ({ + 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, vulnerabilities }; + return { deviceGroups, vulnerabilities, remediations, assets }; } diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index 03d46e53..3648766f 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,6 +16,7 @@ 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(), @@ -26,29 +25,40 @@ export const extractedDeviceGroupSchema = z.object({ export const extractedVulnerabilitySchema = z.object({ cveId: z.string().regex(/^CVE-\d{4}-\d{4,}$/i).nullish(), - manufacturer: z.string().nullish(), - modelName: 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() }) -// 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) + vulnerabilities: z.array(extractedVulnerabilitySchema), + remediations: z.array(extractedRemediationSchema), + assets: z.array(extractedAssetSchema) }); export type ExtractedDeviceGroup = z.infer; export type ExtractedVulnerability = z.infer; +export type ExtractedRemediation = z.infer; +export type ExtractedAsset = z.infer; export type ExtractResult = z.infer; const MODEL = "claude-haiku-4-5-20251001"; 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 and VULNERABILITIES 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: +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: a device-label identifier (distinct from a CPE) - manufacturer / vendor (e.g. "Philips", "GE Healthcare") - model name (e.g. "IntelliVue MX40", "Alaris Pump") - version / firmware (e.g. "2.3.1") @@ -56,13 +66,20 @@ For DEVICE GROUPS: 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. -- manufacturer / modelName of the affected device if mentioned alongside the CVE + +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 (IPv4 or IPv6) +- hostname 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 1ffa16f4..12c7fe07 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -7,7 +7,7 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { z } from "zod"; import type { ConfidenceLevel } from "@/generated/prisma"; import prisma from "@/lib/db"; -import { resolveMatchingId } from "@/lib/router-utils"; +import { addVendorAlias, addProductAlias, enrichDeviceGroupIdentifiers, resolveMatchingId } from "@/lib/router-utils"; import type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -17,6 +17,7 @@ const MODEL = "claude-haiku-4-5-20251001"; // optional so the schema stays a top-level object (Anthropic requirement). const deviceGroupFieldsSchema = z.object({ cpe: z.string().nullish(), + udi: z.string().nullish(), manufacturer: z.string().nullish(), modelName: z.string().nullish(), version: z.string().nullish(), @@ -24,7 +25,7 @@ const deviceGroupFieldsSchema = z.object({ }); const decisionSchema = z.object({ - kind: z.enum(["deviceGroupMatching", "vulnerability"]), // 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(), @@ -56,10 +57,6 @@ For each extracted device group, choose exactly ONE action: - "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. Set targetId to that candidate's id. -For - CONFIDENCE (you may only use these two): - "Matched": strong identifier match (same CVE id, or same manufacturer + model). - "NeedsReview": plausible but uncertain match, or a newly created record that a human should verify. @@ -69,8 +66,9 @@ Always give a concise reasonWhy explaining the match. Only emit decisions you ar function renderCandidates(candidates: Candidates): string { const sections: string[] = []; - if (candidates.deviceGroups.length === 0) - return "(no device groups extracted)"; + 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( @@ -97,7 +95,7 @@ function renderCandidates(candidates: Candidates): string { sections.push("\n"+ candidates.vulnerabilities.map((entry, i) => { const e = entry.extracted; - const line = `Vulnerability #${i+1}: cveId=${e.cveId ?? "?"} | | manufacturer=${e.manufacturer ?? "?"} | modelName=${e.modelName ?? "?"}`; + 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)"}`).join("\n") @@ -109,6 +107,38 @@ function renderCandidates(candidates: Candidates): string { ); } + 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 ?? "?"} | ip=${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: \m${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 ?? "?"}`; + const matches = entry.matches.length > 0 + ? entry.matches + .map((m) => ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname} ?? "(none)"}`).join("\n") + : " - (no candidates found)"; + + return `${line}\n Candidates: \m${matches}`; + }) + .join("\n\n"), + ); + } + return sections.length > 0 ? sections.join("\n\n") : "(No Match Record Found)" } @@ -117,11 +147,13 @@ function cleanFields(fields: Decision["fields"]): { manufacturer?: string; modelName?: string; version?: string; - versionRange? :string + versionRange? :string; + cpe?: string; + udi?: string; } { const out: Record = {}; if (!fields) return out; - for (const key of ["manufacturer", "modelName", "version", "versionRange"] as const) { + for (const key of ["manufacturer", "modelName", "version", "versionRange", "cpe", "udi"] as const) { const val = fields[key]; if (typeof val === "string" && val.trim().length > 0) out[key] = val.trim(); } @@ -172,10 +204,22 @@ 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)), - ...candidates.vulnerabilities.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) { @@ -198,19 +242,31 @@ export async function applyDecisions( 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.has(decision.targetId)) { + 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.has(decision.targetId)) { + if (!decision.targetId || !validIds.deviceGroupMatching.has(decision.targetId)) { summary.skipped++; continue; } @@ -218,44 +274,32 @@ export async function applyDecisions( // 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.versionRange){ - const existing = await tx.deviceGroupMatching.findUnique({ - where: { id: decision.targetId }, - select: { versionRange: true } - }); - if( existing && !existing.versionRange) { + + 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); } } - // 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({ - // where: { id: decision.targetId }, - // data: { cpe: { push: data.cpe } }, - // }); - // } - // } await upsertMapping(decision.targetId); + await enrichDeviceGroup(decision.targetId, data); summary.updated++; } else { // create const data = cleanFields(decision.fields); - // const cpe = data.cpe; - // if (!cpe) { - // 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); if(!data.manufacturer){ summary.skipped++; continue; @@ -268,10 +312,11 @@ export async function applyDecisions( hasCpe: false }) await upsertMapping(matchingId); + await enrichDeviceGroup(matchingId, data); summary.created++; } } else if (decision.kind === "vulnerability") { - if(!decision.targetId || !validIds.has(decision.targetId)) { + if(!decision.targetId || !validIds.vulnerability.has(decision.targetId)) { summary.skipped++; continue; } @@ -283,6 +328,32 @@ export async function applyDecisions( update: { confidence, reasonWhy: decision.reasonWhy} }); summary.linked++; + } else if (decision.kind === "remediation") { + if(!decision.targetId || !validIds.remediation.has(decision.targetId)) { + summary.skipped++; + continue; + } + 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 }, + }); + summary.linked++; + } else if (decision.kind === "asset") { + if(!decision.targetId || !validIds.asset.has(decision.targetId)) { + summary.skipped++; + continue; + } + 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 }, + }); + summary.linked++; } else { summary.skipped++; } diff --git a/src/lib/router-utils.ts b/src/lib/router-utils.ts index 83e6810e..093f2dd2 100644 --- a/src/lib/router-utils.ts +++ b/src/lib/router-utils.ts @@ -167,6 +167,41 @@ export async function resolveVersion( } } +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 }} + }); +}; + + // ============================================================================ // DeviceGroup resolution // ============================================================================ @@ -233,7 +268,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; @@ -251,6 +285,36 @@ export async function resolveDeviceGroup(identity: DeviceGroupIdentityInput) { return deviceGroup; } +// This is similiar to the above 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 } : {}), + } + }) + } + const CPE_UNKNOWN_TOKENS = new Set(["", "-", "*"]); /** From 053567fa543b15a927fad205120b6eec5ea022ac Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 09:46:00 -0400 Subject: [PATCH 05/15] apply FE changes for deviceGroupMatching updates, update seed-notifications --- scripts/seed-notifications.ts | 11 ++- src/features/inbox/components/columns.tsx | 12 +-- .../notification-affected-assets-tab.tsx | 16 ++-- .../inbox/components/notification-detail.tsx | 9 +- src/features/inbox/server/routers.ts | 94 ++++++++++++++----- src/features/inbox/types.ts | 49 ++++++---- 6 files changed, 128 insertions(+), 63 deletions(-) diff --git a/scripts/seed-notifications.ts b/scripts/seed-notifications.ts index 0e621fd5..be680a49 100644 --- a/scripts/seed-notifications.ts +++ b/scripts/seed-notifications.ts @@ -81,7 +81,10 @@ 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 +107,7 @@ 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 +358,7 @@ async function getSeedUser() { async function seedNotifications( _userId: string, - deviceGroups: Array<{ deviceGroupId: string }>, + deviceGroups: Array<{ deviceGroupMatchingId: string }>, ) { console.log("\n🌱 Seeding notifications..."); @@ -407,7 +410,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/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..85c4f21c 100644 --- a/src/features/inbox/components/notification-affected-assets-tab.tsx +++ b/src/features/inbox/components/notification-affected-assets-tab.tsx @@ -11,16 +11,16 @@ 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) { @@ -37,9 +37,9 @@ export function NotificationAffectedAssetsTab({ - {deviceGroupLabel(mapping.deviceGroup)} + {deviceGroupMatchingLabel(mapping.deviceGroupMatching)} - {mapping.deviceGroup._count.assets} + {mapping.assetCount} @@ -55,7 +55,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..5e0ccfc7 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,47 @@ 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 +123,19 @@ 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 +155,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..9b7dc049 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,24 @@ export const notificationInclude = { }, } satisfies Prisma.NotificationInclude; -export type NotificationWithRelations = Prisma.NotificationGetPayload<{ - include: typeof notificationInclude; +type NotificationBasePayload = Prisma.NotificationGetPayload<{ + include: typeof notificationInclude }>; +export type NotificationWithRelations = Omit & { + 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 +58,26 @@ 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"> & { + deviceGroupMatchings: (NotificationDetailBasePayload["deviceGroupsMatchings"][number] & { + assetCount: number; + assets: ResolvedDeviceGroupAsset[]; + })[]; +}; + export type NotificationDetailSource = NotificationDetailWithRelations["sources"][number]; From c69537ee463a85b6864e44e39c03020b9b00b6d5 Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 12:52:43 -0400 Subject: [PATCH 06/15] redo migration, minor typo fixes --- prisma/schema.prisma | 2 +- src/features/inbox/agent/candidate-search.ts | 6 ++--- src/features/inbox/agent/match.ts | 23 ++++++++++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6215bb1e..807b6ec6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1258,7 +1258,7 @@ model NotificationDeviceGroupMapping { reasonWhy String? @@unique([notificationId, deviceGroupMatchingId]) - @@unique([workOrderTicketId, deviceGroupMatchingId], map: "ndg_mapping_workorder_devicegroup_key") + @@unique([workOrderTicketId, deviceGroupMatchingId], map: "ndg_mapping_workorder_devicegroupmatching_key") @@index([workOrderTicketId]) @@map("notification_device_group_mapping") diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index 25467517..e2e9a668 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -97,7 +97,7 @@ async function searchDeviceGroupMatching( const identity = { vendorId: deviceGroup.vendorId, - prodcutId: deviceGroup.productId, + productId: deviceGroup.productId, versionId: deviceGroup.versionId }; @@ -126,7 +126,7 @@ async function searchDeviceGroupMatching( extracted.modelName, ].filter((t): t is string => !!t && t.trim().length > 0); - if (terms.length === 0) return []; + if (terms.length === 0) return [...matched.values()]; const or: Prisma.DeviceGroupMatchingWhereInput[] = []; // TODO: consider something like a fuzzy search? @@ -223,7 +223,7 @@ async function searchRemediation(extracted: ExtractedRemediation): Promise { const or: Prisma.AssetWhereInput[] = []; if(extracted.ip) { - or.push({ip: {contains: extracted.ip}}) + or.push({ip: {contains: extracted.ip, mode: "insensitive"}}) } if(extracted.hostname) { or.push({ hostname: { contains: extracted.hostname, mode: "insensitive"}}) diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 12c7fe07..3acbbf79 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -57,8 +57,17 @@ For each extracted device group, choose exactly ONE action: - "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. + +For each extracted Remediation, choose exactly ONE action: +- "link": the remediation clearly matches an existing candidate. Set targetId to that candidate's id. + +For each extracted Asset, choose exactly ONE action: +- "link": the asset clearly matches an existing candidate (same IP/hostname). Set targetId to that candidate's id. + CONFIDENCE (you may only use these two): -- "Matched": strong identifier match (same CVE id, or same manufacturer + model). +- "Matched": strong identifier match (same CVE id, same IP/hostname, 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 extracted entities.`; @@ -101,7 +110,7 @@ function renderCandidates(candidates: Candidates): string { .map((m) => ` - id: ${m.id} | cveId: ${m.cveId ?? "(none)"} | description: ${m.description} ?? "(none)"}`).join("\n") : " - (no candidates found)"; - return `${line}\n Candidates: \m${matches}`; + return `${line}\n Candidates: \n${matches}`; }) .join("\n\n"), ); @@ -111,13 +120,13 @@ function renderCandidates(candidates: Candidates): string { sections.push("\n"+ candidates.remediations.map((entry, i) => { const e = entry.extracted; - const line = `Remediations #${i+1}: linkedtoCveId=${e.linkedCveId ?? "?"} | ip=${e.description ?? "?"}`; + 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") + .map((m) => ` - id: ${m.id} | linkedtoCveId: ${m.linkedCveId ?? "(none)"} | description: ${m.description ?? "(none)"}`).join("\n") : " - (no candidates found)"; - return `${line}\n Candidates: \m${matches}`; + return `${line}\n Candidates: \n${matches}`; }) .join("\n\n"), ); @@ -127,13 +136,13 @@ function renderCandidates(candidates: Candidates): string { sections.push("\n"+ candidates.assets.map((entry, i) => { const e = entry.extracted; - const line = `Asset #${i+1}: ip=${e.ip ?? "?"} | hostname=${e.hostname ?? "?"}`; + const line = `Asset #${i+1}: ip=${e.ip ?? "?"} | hostname=${e.hostname} ?? "?"}`; const matches = entry.matches.length > 0 ? entry.matches .map((m) => ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname} ?? "(none)"}`).join("\n") : " - (no candidates found)"; - return `${line}\n Candidates: \m${matches}`; + return `${line}\n Candidates: \n${matches}`; }) .join("\n\n"), ); From 6a715b2aef7709ee49f4a3a6ea2401e04e5e339d Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 13:22:39 -0400 Subject: [PATCH 07/15] add missing file --- src/lib/router-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/router-utils.ts b/src/lib/router-utils.ts index 7ea5b3fd..d92b3bb1 100644 --- a/src/lib/router-utils.ts +++ b/src/lib/router-utils.ts @@ -337,7 +337,7 @@ export async function enrichDeviceGroupIdentifiers( 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; + if (!needsCpe && !needsUdi) return; await prisma.deviceGroup.update({ where: { id: deviceGroup.id }, From d70f02e5fe18641bcaa17b48b90571f55fe3285e Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 13:29:04 -0400 Subject: [PATCH 08/15] fix linting --- scripts/seed-notifications.ts | 12 +- src/features/inbox/agent/candidate-search.ts | 217 +++++++----- src/features/inbox/agent/extract.ts | 22 +- src/features/inbox/agent/match.ts | 325 ++++++++++++------ .../notification-affected-assets-tab.tsx | 4 +- src/features/inbox/server/routers.ts | 59 ++-- src/features/inbox/types.ts | 27 +- src/lib/router-utils.ts | 94 ++--- 8 files changed, 465 insertions(+), 295 deletions(-) diff --git a/scripts/seed-notifications.ts b/scripts/seed-notifications.ts index be680a49..e00e49a3 100644 --- a/scripts/seed-notifications.ts +++ b/scripts/seed-notifications.ts @@ -82,9 +82,11 @@ async function seedDeviceGroups(userId: string) { const deviceGroup = existing ?? (await prisma.deviceGroup.create({ data: identity })); const existingMatching = await prisma.deviceGroupMatching.findFirst({ - where: { vendorId: vendor.id, productId: product.id, versionId: null } + where: { vendorId: vendor.id, productId: product.id, versionId: null }, }); - const deviceGroupMatching = existingMatching ?? (await prisma.deviceGroupMatching.create({ data: identity})) + const deviceGroupMatching = + existingMatching ?? + (await prisma.deviceGroupMatching.create({ data: identity })); const existingAssets = await prisma.asset.count({ where: { deviceGroupId: deviceGroup.id }, }); @@ -107,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, deviceGroupMatchingId: deviceGroupMatching.id }); + results.push({ + ...dg, + deviceGroupId: deviceGroup.id, + deviceGroupMatchingId: deviceGroupMatching.id, + }); } return results; diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index e2e9a668..b6696d33 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -3,8 +3,14 @@ import "server-only"; import type { Prisma } from "@/generated/prisma"; import prisma from "@/lib/db"; -import type { ExtractedAsset, ExtractedDeviceGroup, ExtractedRemediation, ExtractedVulnerability, ExtractResult } from "./extract"; -import { normalizeName } from "@/lib/router-utils" +import type { + ExtractedAsset, + ExtractedDeviceGroup, + ExtractedRemediation, + ExtractedVulnerability, + ExtractResult, +} from "./extract"; +import { normalizeName } from "@/lib/router-utils"; import { PrimitiveAtom } from "jotai"; const TOP_K = 5; @@ -20,7 +26,7 @@ export type DeviceGroupMatchingCandidate = { export type VulnerabilitiyCandidate = { id: string; cveId: string | null; - description: string | null + description: string | null; }; export type RemediationCandidate = { @@ -43,88 +49,90 @@ export type Candidates = { }>; vulnerabilities: Array<{ extracted: ExtractedVulnerability; - matches: VulnerabilitiyCandidate[] + matches: VulnerabilitiyCandidate[]; }>; remediations: Array<{ extracted: ExtractedRemediation; - matches: RemediationCandidate[] - }>, + matches: RemediationCandidate[]; + }>; assets: Array<{ extracted: ExtractedAsset; - matches: AssetCandidate[] - }> + matches: AssetCandidate[]; + }>; }; const nameOrClauses = (term: string) => [ - { canonicalName: { contains: term, mode: "insensitive" as const }}, - { canonicalDisplayName: { contains: term, mode: "insensitive" as const }}, - { nameMappings: { has: normalizeName(term) }} + { 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) +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 searchDeviceGroupMatching( extracted: ExtractedDeviceGroup, ): 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.cpe) { + identifierWhere.push({ cpe: { has: extracted.cpe } }); } - if(extracted.udi) { - identifierWhere.push({ udi: extracted.udi}) + if (extracted.udi) { + identifierWhere.push({ udi: extracted.udi }); } - if(identifierWhere.length > 0){ + if (identifierWhere.length > 0) { const deviceGroup = await prisma.deviceGroup.findFirst({ - where: { OR : identifierWhere }, - select : { vendorId: true, productId: true, versionId: true } + where: { OR: identifierWhere }, + select: { vendorId: true, productId: true, versionId: true }, }); - if(deviceGroup?.vendorId) { + if (deviceGroup?.vendorId) { const matchingSelect = { id: true, - vendor: { select : { canonicalDisplayName: true }}, - product: { select: { canonicalDisplayName: true }}, - version: { select: { canonicalDisplayName: true }}, - versionRange: true + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + version: { select: { canonicalDisplayName: true } }, + versionRange: true, } as const; const identity = { vendorId: deviceGroup.vendorId, productId: deviceGroup.productId, - versionId: deviceGroup.versionId + versionId: deviceGroup.versionId, }; const existingMatching = await prisma.deviceGroupMatching.findFirst({ where: identity, - select: matchingSelect + select: matchingSelect, }); - const matching = existingMatching ?? (await prisma.deviceGroupMatching.create({ - data: 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 - }) + versionRange: matching.versionRange, + }); } } - const terms = [ - extracted.manufacturer, - extracted.modelName, - ].filter((t): t is string => !!t && t.trim().length > 0); + const terms = [extracted.manufacturer, extracted.modelName].filter( + (t): t is string => !!t && t.trim().length > 0, + ); if (terms.length === 0) return [...matched.values()]; @@ -147,88 +155,102 @@ async function searchDeviceGroupMatching( vendor: { select: { canonicalDisplayName: true } }, product: { select: { canonicalDisplayName: true } }, version: { select: { canonicalDisplayName: true } }, - versionRange: true + versionRange: true, }, take: TOP_K, }); - - for(const row of rows) { + 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 + versionRange: row.versionRange, }); } - return [...matched.values()] + return [...matched.values()]; } -async function searchVulnerability(extracted: ExtractedVulnerability): Promise { +async function searchVulnerability( + extracted: ExtractedVulnerability, +): Promise { const or: Prisma.VulnerabilityWhereInput[] = []; - if(extracted.cveId) { - or.push({ cveId: { contains: extracted.cveId, mode: "insensitive"}}) + if (extracted.cveId) { + or.push({ cveId: { contains: extracted.cveId, mode: "insensitive" } }); } - if(or.length === 0) return []; + if (or.length === 0) return []; const rows = await prisma.vulnerability.findMany({ where: { OR: or }, select: { id: true, cveId: true, description: true }, - take: TOP_K + take: TOP_K, }); return rows.map((row) => ({ id: row.id, cveId: row.cveId ?? null, - description: row.description ?? null + description: row.description ?? null, })); } -async function searchRemediation(extracted: ExtractedRemediation): Promise { +async function searchRemediation( + extracted: ExtractedRemediation, +): Promise { const or: Prisma.RemediationWhereInput[] = []; - if(extracted.linkedCveId) { - or.push({ vulnerability: { cveId: { contains: extracted.linkedCveId, mode: "insensitive"}}}); + 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 (extracted.description) { + const insensitive = { + contains: extracted.description, + mode: "insensitive" as const, + }; + or.push({ description: insensitive }, { narrative: insensitive }); } - if( or.length === 0) return []; + if (or.length === 0) return []; const rows = await prisma.remediation.findMany({ where: { OR: or }, select: { id: true, description: true, - vulnerability: { select: {cveId: true }}, + vulnerability: { select: { cveId: true } }, deviceGroupMatchings: { select: { - vendor: { select : { canonicalDisplayName: true}}, - product: { select : { canonicalDisplayName: true}} + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, }, - take: 1 + take: 1, }, }, - take: TOP_K + take: TOP_K, }); return rows.map((row) => ({ id: row.id, linkedCveId: row.vulnerability?.cveId ?? null, description: row.description ?? null, - })) + })); } -async function searchAsset(extracted: ExtractedAsset): Promise { +async function searchAsset( + extracted: ExtractedAsset, +): Promise { const or: Prisma.AssetWhereInput[] = []; - if(extracted.ip) { - or.push({ip: {contains: extracted.ip, mode: "insensitive"}}) + if (extracted.ip) { + or.push({ ip: { contains: extracted.ip, mode: "insensitive" } }); } - if(extracted.hostname) { - or.push({ hostname: { contains: extracted.hostname, mode: "insensitive"}}) + if (extracted.hostname) { + or.push({ + hostname: { contains: extracted.hostname, mode: "insensitive" }, + }); } - if(or.length === 0) return []; + if (or.length === 0) return []; const rows = await prisma.asset.findMany({ where: { OR: or }, @@ -238,44 +260,51 @@ async function searchAsset(extracted: ExtractedAsset): Promise hostname: true, deviceGroup: { select: { - vendor: { select : {canonicalDisplayName: true}}, - product: { select : {canonicalDisplayName:true}} - } - } + vendor: { select: { canonicalDisplayName: true } }, + product: { select: { canonicalDisplayName: true } }, + }, + }, }, - take: TOP_K + take: TOP_K, }); return rows.map((row) => ({ id: row.id, ip: row.ip ?? null, - hostname: row.hostname ?? null + hostname: row.hostname ?? null, })); } export async function searchCandidates( extracted: ExtractResult, ): Promise { - 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) - }))) - ] - ); + 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, vulnerabilities, remediations, assets }; } diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index 3648766f..43676623 100644 --- a/src/features/inbox/agent/extract.ts +++ b/src/features/inbox/agent/extract.ts @@ -24,28 +24,36 @@ export const extractedDeviceGroupSchema = z.object({ }); export const extractedVulnerabilitySchema = z.object({ - cveId: z.string().regex(/^CVE-\d{4}-\d{4,}$/i).nullish(), + cveId: z + .string() + .regex(/^CVE-\d{4}-\d{4,}$/i) + .nullish(), }); export const extractedRemediationSchema = z.object({ - linkedCveId: z.string().regex(/^CVE-\d{4}-\d{4,}$/i).nullish(), - description: z.string().nullish() + 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() -}) + hostname: z.string().nullish(), +}); export const extractSchema = z.object({ deviceGroups: z.array(extractedDeviceGroupSchema), vulnerabilities: z.array(extractedVulnerabilitySchema), remediations: z.array(extractedRemediationSchema), - assets: z.array(extractedAssetSchema) + assets: z.array(extractedAssetSchema), }); export type ExtractedDeviceGroup = z.infer; -export type ExtractedVulnerability = z.infer; +export type ExtractedVulnerability = z.infer< + typeof extractedVulnerabilitySchema +>; export type ExtractedRemediation = z.infer; export type ExtractedAsset = z.infer; export type ExtractResult = z.infer; diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 3acbbf79..8bc03966 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -7,7 +7,12 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { z } from "zod"; import type { ConfidenceLevel } from "@/generated/prisma"; import prisma from "@/lib/db"; -import { addVendorAlias, addProductAlias, enrichDeviceGroupIdentifiers, resolveMatchingId } from "@/lib/router-utils"; +import { + addVendorAlias, + addProductAlias, + enrichDeviceGroupIdentifiers, + resolveMatchingId, +} from "@/lib/router-utils"; import type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -21,11 +26,16 @@ const deviceGroupFieldsSchema = z.object({ manufacturer: z.string().nullish(), modelName: z.string().nullish(), version: z.string().nullish(), - versionRange: z.string().nullish() + versionRange: z.string().nullish(), }); const decisionSchema = z.object({ - kind: z.enum(["deviceGroupMatching", "vulnerability", "remediation", "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(), @@ -75,80 +85,108 @@ Always give a concise reasonWhy explaining the match. Only emit decisions you ar function renderCandidates(candidates: Candidates): string { const sections: string[] = []; - if(candidates.deviceGroups.length === 0 && candidates.vulnerabilities.length === 0 && candidates.remediations.length === 0 && candidates.assets.length === 0){ + 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) { + 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") + .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)"}`).join("\n") - : " - (no candidates found)"; - - return `${line}\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)"}`, + ) + .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.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} ?? "?"}`; - const matches = entry.matches.length > 0 - ? entry.matches - .map((m) => ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname} ?? "(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} ?? "?"}`; + const matches = + entry.matches.length > 0 + ? entry.matches + .map( + (m) => + ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname} ?? "(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)" + return sections.length > 0 + ? sections.join("\n\n") + : "(No Match Record Found)"; } // Build a Prisma data object from LLM-provided fields, dropping empties. @@ -156,13 +194,20 @@ function cleanFields(fields: Decision["fields"]): { manufacturer?: string; modelName?: string; version?: string; - versionRange? :string; + versionRange?: string; cpe?: string; udi?: string; } { const out: Record = {}; if (!fields) return out; - for (const key of ["manufacturer", "modelName", "version", "versionRange", "cpe", "udi"] as const) { + for (const key of [ + "manufacturer", + "modelName", + "version", + "versionRange", + "cpe", + "udi", + ] as const) { const val = fields[key]; if (typeof val === "string" && val.trim().length > 0) out[key] = val.trim(); } @@ -214,7 +259,6 @@ export async function applyDecisions( // Only allow link/update against ids the search actually surfaced — guards // against hallucinated targetIds causing FK errors. - const validIds = { deviceGroupMatching: new Set( candidates.deviceGroups.flatMap((e) => e.matches.map((m) => m.id)), @@ -227,7 +271,7 @@ export async function applyDecisions( ), asset: new Set( candidates.assets.flatMap((e) => e.matches.map((m) => m.id)), - ) + ), }; await prisma.$transaction(async (tx) => { @@ -236,46 +280,64 @@ export async function applyDecisions( const confidence: ConfidenceLevel = decision.confidence === "Matched" ? "Matched" : "NeedsReview"; - if(decision.kind === "deviceGroupMatching"){ + 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 }, - }); + 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 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}, + where: { id: deviceGroupMatchingId }, + select: { vendorId: true, productId: true, versionId: true }, }); - if(matching) { - await enrichDeviceGroupIdentifiers(matching, { cpe: data.cpe, udi: data.udi }); + 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)) { + if ( + !decision.targetId || + !validIds.deviceGroupMatching.has(decision.targetId) + ) { summary.skipped++; continue; } await upsertMapping(decision.targetId); - await enrichDeviceGroup(decision.targetId, cleanFields(decision.fields)); + 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)) { + if ( + !decision.targetId || + !validIds.deviceGroupMatching.has(decision.targetId) + ) { summary.skipped++; continue; } @@ -283,23 +345,32 @@ export async function applyDecisions( // 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 } + 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 ( + data.versionRange && + targetMatching && + !targetMatching.versionRange + ) { + await tx.deviceGroupMatching.update({ + where: { id: decision.targetId }, + data: { versionRange: data.versionRange }, + }); } - if(targetMatching) { - if(data.manufacturer) { + if (targetMatching) { + if (data.manufacturer) { await addVendorAlias(targetMatching.vendorId, data.manufacturer); } - if(data.modelName && targetMatching.productId) { + if (data.modelName && targetMatching.productId) { await addProductAlias(targetMatching.productId, data.modelName); } } @@ -309,7 +380,7 @@ export async function applyDecisions( } else { // create const data = cleanFields(decision.fields); - if(!data.manufacturer){ + if (!data.manufacturer) { summary.skipped++; continue; } @@ -318,48 +389,78 @@ export async function applyDecisions( product: data.modelName, version: data.version, versionRange: data.versionRange, - hasCpe: false - }) + hasCpe: false, + }); await upsertMapping(matchingId); await enrichDeviceGroup(matchingId, data); summary.created++; } } else if (decision.kind === "vulnerability") { - if(!decision.targetId || !validIds.vulnerability.has(decision.targetId)) { + if ( + !decision.targetId || + !validIds.vulnerability.has(decision.targetId) + ) { summary.skipped++; continue; } await tx.notificationVulnerabilityMapping.upsert({ where: { - notificationId_vulnerabilityId: { notificationId, vulnerabilityId: decision.targetId} + notificationId_vulnerabilityId: { + notificationId, + vulnerabilityId: decision.targetId, + }, + }, + create: { + notificationId, + vulnerabilityId: decision.targetId, + confidence, + reasonWhy: decision.reasonWhy, }, - create: { notificationId, vulnerabilityId: decision.targetId, confidence, reasonWhy: decision.reasonWhy}, - update: { confidence, reasonWhy: decision.reasonWhy} + update: { confidence, reasonWhy: decision.reasonWhy }, }); summary.linked++; } else if (decision.kind === "remediation") { - if(!decision.targetId || !validIds.remediation.has(decision.targetId)) { + if ( + !decision.targetId || + !validIds.remediation.has(decision.targetId) + ) { summary.skipped++; continue; } await tx.notificationRemediationMapping.upsert({ where: { - notificationId_remediationId: { notificationId, remediationId: decision.targetId } - }, - create: { notificationId, remediationId: decision.targetId, confidence, reasonWhy: decision.reasonWhy}, + notificationId_remediationId: { + notificationId, + remediationId: decision.targetId, + }, + }, + create: { + notificationId, + remediationId: decision.targetId, + confidence, + reasonWhy: decision.reasonWhy, + }, update: { confidence, reasonWhy: decision.reasonWhy }, }); summary.linked++; } else if (decision.kind === "asset") { - if(!decision.targetId || !validIds.asset.has(decision.targetId)) { + if (!decision.targetId || !validIds.asset.has(decision.targetId)) { summary.skipped++; continue; } await tx.notificationAssetMapping.upsert({ where: { - notificationId_assetId: { notificationId, assetId: decision.targetId } - }, - create: { notificationId, assetId: decision.targetId, confidence, reasonWhy: decision.reasonWhy}, + notificationId_assetId: { + notificationId, + assetId: decision.targetId, + }, + }, + create: { + notificationId, + assetId: decision.targetId, + confidence, + reasonWhy: decision.reasonWhy, + }, update: { confidence, reasonWhy: decision.reasonWhy }, }); summary.linked++; diff --git a/src/features/inbox/components/notification-affected-assets-tab.tsx b/src/features/inbox/components/notification-affected-assets-tab.tsx index 85c4f21c..b19bae76 100644 --- a/src/features/inbox/components/notification-affected-assets-tab.tsx +++ b/src/features/inbox/components/notification-affected-assets-tab.tsx @@ -19,9 +19,7 @@ export function NotificationAffectedAssetsTab({ }: { deviceGroupsMatchings: NotificationDetailWithRelations["deviceGroupsMatchings"]; }) { - const withAssets = deviceGroupsMatchings.filter( - (m) => m.assetCount > 0, - ); + const withAssets = deviceGroupsMatchings.filter((m) => m.assetCount > 0); if (withAssets.length === 0) { return ( diff --git a/src/features/inbox/server/routers.ts b/src/features/inbox/server/routers.ts index 5e0ccfc7..2e9badd4 100644 --- a/src/features/inbox/server/routers.ts +++ b/src/features/inbox/server/routers.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { NotificationType, Priority } from "@/generated/prisma"; import prisma from "@/lib/db"; import { - deviceGroupWhereForMatching, + deviceGroupWhereForMatching, matchingAppliesToDeviceGroup, } from "@/lib/device-matching"; import { @@ -39,18 +39,20 @@ async function resolvedDeviceGroupAssetCount( matching: MatchingIdentity, ): Promise { const candidates = await prisma.deviceGroup.findMany({ - where: deviceGroupWhereForMatching(matching), + where: deviceGroupWhereForMatching(matching), select: { id: true, vendorId: true, - productId: true, + productId: true, versionId: true, - version: { select: { canonicalName: true }}, - _count: { select: { assets: true }} + version: { select: { canonicalName: true } }, + _count: { select: { assets: true } }, }, }); - return candidates.filter((dg) => matchingAppliesToDeviceGroup(matching, dg)).reduce((sum, dg) => sum +dg._count.assets, 0); -}; + 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({ @@ -60,22 +62,23 @@ async function resolveDeviceGroupAssets(matching: MatchingIdentity) { vendorId: true, productId: true, versionId: true, - version: { select: { canonicalName: true }}, + version: { select: { canonicalName: true } }, assets: { select: { id: true, ip: true, hostname: true, location: true, - status: true - } - } - } + status: true, + }, + }, + }, }); - return candidates.filter((dg) => matchingAppliesToDeviceGroup(matching, dg)).flatMap((dg) => dg.assets); + return candidates + .filter((dg) => matchingAppliesToDeviceGroup(matching, dg)) + .flatMap((dg) => dg.assets); } - export const notificationsRouter = createTRPCRouter({ getMany: protectedProcedure .input( @@ -125,15 +128,17 @@ export const notificationsRouter = createTRPCRouter({ 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), - })), - ), - })), - ); + ...n, + deviceGroupsMatchings: await Promise.all( + n.deviceGroupsMatchings.map(async (m) => ({ + ...m, + assetCount: await resolvedDeviceGroupAssetCount( + m.deviceGroupMatching, + ), + })), + ), + })), + ); return createPaginatedResponse(items, meta); }), @@ -158,10 +163,10 @@ export const notificationsRouter = createTRPCRouter({ 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}; + 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 9b7dc049..b75d1eb8 100644 --- a/src/features/inbox/types.ts +++ b/src/features/inbox/types.ts @@ -22,11 +22,16 @@ export const notificationInclude = { } satisfies Prisma.NotificationInclude; type NotificationBasePayload = Prisma.NotificationGetPayload<{ - include: typeof notificationInclude + include: typeof notificationInclude; }>; -export type NotificationWithRelations = Omit & { - deviceGroupsMatchings: ( NotificationBasePayload["deviceGroupsMatchings"][number] & { assetCount: number})[] +export type NotificationWithRelations = Omit< + NotificationBasePayload, + "deviceGroupsMatchings" +> & { + deviceGroupsMatchings: (NotificationBasePayload["deviceGroupsMatchings"][number] & { + assetCount: number; + })[]; }; export type NotificationSource = NotificationWithRelations["sources"][number]; @@ -38,7 +43,7 @@ export const notificationDetailInclude = { include: { vendor: true, product: true, - version: true + version: true, }, }, }, @@ -68,14 +73,16 @@ export type ResolvedDeviceGroupAsset = { hostname: string | null; location: unknown; status: AssetStatus | null; -} +}; export type NotificationDetailWithRelations = Omit< - NotificationDetailBasePayload, "deviceGroupsMatchings"> & { - deviceGroupMatchings: (NotificationDetailBasePayload["deviceGroupsMatchings"][number] & { - assetCount: number; - assets: ResolvedDeviceGroupAsset[]; - })[]; + NotificationDetailBasePayload, + "deviceGroupsMatchings" +> & { + deviceGroupMatchings: (NotificationDetailBasePayload["deviceGroupsMatchings"][number] & { + assetCount: number; + assets: ResolvedDeviceGroupAsset[]; + })[]; }; export type NotificationDetailSource = diff --git a/src/lib/router-utils.ts b/src/lib/router-utils.ts index d92b3bb1..e9fd6242 100644 --- a/src/lib/router-utils.ts +++ b/src/lib/router-utils.ts @@ -204,36 +204,44 @@ 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} + select: { canonicalName: true, nameMappings: true }, }); - if(!row) return; - if(normalized === row.canonicalName || row.nameMappings.includes(normalized)) { + if (!row) return; + if ( + normalized === row.canonicalName || + row.nameMappings.includes(normalized) + ) { return; } await prisma.vendor.update({ - where:{ id }, - data: { nameMappings: { push: normalized }} + where: { id }, + data: { nameMappings: { push: normalized } }, }); -}; +} -export async function addProductAlias(id: string, alias: string): Promise { +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 } + select: { canonicalName: true, nameMappings: true }, }); - if(!row) return; - if(normalized === row.canonicalName || row.nameMappings.includes(normalized)) { + if (!row) return; + if ( + normalized === row.canonicalName || + row.nameMappings.includes(normalized) + ) { return; } await prisma.product.update({ - where:{ id }, - data: { nameMappings: { push: normalized }} + where: { id }, + data: { nameMappings: { push: normalized } }, }); -}; - +} // ============================================================================ // DeviceGroup resolution @@ -322,31 +330,37 @@ export async function resolveDeviceGroup(identity: DeviceGroupIdentityInput) { // 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; + 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; - await prisma.deviceGroup.update({ - where: { id: deviceGroup.id }, - data: { - ...(needsCpe ? { cpe: mergedCpes } : {}), - ...(needsUdi ? { udi: updates.udi } : {}), - } - }) - } + 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 } : {}), + }, + }); +} const CPE_UNKNOWN_TOKENS = new Set(["", "-", "*"]); @@ -427,7 +441,9 @@ type MatchingResolveInput = { * identity (resolved to canonical rows). Identities come from CPEs, so * canonicals are marked CPE-backed. */ -export async function resolveMatchingId(input: MatchingResolveInput): Promise { +export async function resolveMatchingId( + input: MatchingResolveInput, +): Promise { const hasCpe = input.hasCpe ?? true; const vendorRow = await resolveVendor(input.vendor, { hasCpe }); const productRow = input.product From f7af4b57ce11be0da18e8783e32e3b948f0a4b1c Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 13:35:08 -0400 Subject: [PATCH 09/15] add missing migration file --- .../migration.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 prisma/migrations/20260702173451_work_order_id_device_group_matching_id/migration.sql 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"); From a36c3fa43de03ec6b92e2f6070f07fe80243ba8f Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 13:44:21 -0400 Subject: [PATCH 10/15] fix linting --- src/features/inbox/agent/candidate-search.ts | 3 +-- src/features/inbox/agent/match.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index b6696d33..eb42bf28 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -3,6 +3,7 @@ import "server-only"; import type { Prisma } from "@/generated/prisma"; import prisma from "@/lib/db"; +import { normalizeName } from "@/lib/router-utils"; import type { ExtractedAsset, ExtractedDeviceGroup, @@ -10,8 +11,6 @@ import type { ExtractedVulnerability, ExtractResult, } from "./extract"; -import { normalizeName } from "@/lib/router-utils"; -import { PrimitiveAtom } from "jotai"; const TOP_K = 5; diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 8bc03966..2bb816c0 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -8,8 +8,8 @@ import { z } from "zod"; import type { ConfidenceLevel } from "@/generated/prisma"; import prisma from "@/lib/db"; import { - addVendorAlias, addProductAlias, + addVendorAlias, enrichDeviceGroupIdentifiers, resolveMatchingId, } from "@/lib/router-utils"; From 344b932358750aed4f25d0b9fc22ba80e987aa00 Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 13:57:10 -0400 Subject: [PATCH 11/15] fix typo - linting --- package-lock.json | 3 ++- package.json | 2 +- src/features/inbox/types.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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/src/features/inbox/types.ts b/src/features/inbox/types.ts index b75d1eb8..41ede385 100644 --- a/src/features/inbox/types.ts +++ b/src/features/inbox/types.ts @@ -79,7 +79,7 @@ export type NotificationDetailWithRelations = Omit< NotificationDetailBasePayload, "deviceGroupsMatchings" > & { - deviceGroupMatchings: (NotificationDetailBasePayload["deviceGroupsMatchings"][number] & { + deviceGroupsMatchings: (NotificationDetailBasePayload["deviceGroupsMatchings"][number] & { assetCount: number; assets: ResolvedDeviceGroupAsset[]; })[]; From 37bd5ee94667cdafffda0703b0f6e50dc9f0208e Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 21:27:31 -0400 Subject: [PATCH 12/15] add cvssScore, cvssVector, macAddress, serial number, addressed comments --- prisma/schema.prisma | 9 +- src/features/inbox/agent/candidate-search.ts | 24 +++- src/features/inbox/agent/extract.ts | 15 +- src/features/inbox/agent/match.ts | 96 +++++++++++-- src/features/inbox/utils.ts | 139 +++++++++++++++++++ src/lib/router-utils.ts | 79 ----------- 6 files changed, 256 insertions(+), 106 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 807b6ec6..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") diff --git a/src/features/inbox/agent/candidate-search.ts b/src/features/inbox/agent/candidate-search.ts index eb42bf28..8d8c35af 100644 --- a/src/features/inbox/agent/candidate-search.ts +++ b/src/features/inbox/agent/candidate-search.ts @@ -22,10 +22,12 @@ export type DeviceGroupMatchingCandidate = { versionRange: string | null; }; -export type VulnerabilitiyCandidate = { +export type VulnerabilityCandidate = { id: string; cveId: string | null; description: string | null; + cvssScore: number | null; + cvssVector: string | null; }; export type RemediationCandidate = { @@ -38,6 +40,8 @@ export type AssetCandidate = { id: string; ip: string | null; hostname: string | null; + macAddress: string | null; + serialNumber: string | null; }; export type Candidates = { @@ -48,7 +52,7 @@ export type Candidates = { }>; vulnerabilities: Array<{ extracted: ExtractedVulnerability; - matches: VulnerabilitiyCandidate[]; + matches: VulnerabilityCandidate[]; }>; remediations: Array<{ extracted: ExtractedRemediation; @@ -173,7 +177,7 @@ async function searchDeviceGroupMatching( async function searchVulnerability( extracted: ExtractedVulnerability, -): Promise { +): Promise { const or: Prisma.VulnerabilityWhereInput[] = []; if (extracted.cveId) { or.push({ cveId: { contains: extracted.cveId, mode: "insensitive" } }); @@ -183,7 +187,13 @@ async function searchVulnerability( const rows = await prisma.vulnerability.findMany({ where: { OR: or }, - select: { id: true, cveId: true, description: true }, + select: { + id: true, + cveId: true, + description: true, + cvssScore: true, + cvssVector: true, + }, take: TOP_K, }); @@ -191,6 +201,8 @@ async function searchVulnerability( id: row.id, cveId: row.cveId ?? null, description: row.description ?? null, + cvssScore: row.cvssScore ?? null, + cvssVector: row.cvssVector ?? null, })); } @@ -257,6 +269,8 @@ async function searchAsset( id: true, ip: true, hostname: true, + macAddress: true, + serialNumber: true, deviceGroup: { select: { vendor: { select: { canonicalDisplayName: true } }, @@ -271,6 +285,8 @@ async function searchAsset( id: row.id, ip: row.ip ?? null, hostname: row.hostname ?? null, + macAddress: row.macAddress ?? null, + serialNumber: row.serialNumber ?? null, })); } diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index 43676623..73cb7e26 100644 --- a/src/features/inbox/agent/extract.ts +++ b/src/features/inbox/agent/extract.ts @@ -28,6 +28,8 @@ export const extractedVulnerabilitySchema = z.object({ .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({ @@ -41,6 +43,8 @@ export const extractedRemediationSchema = z.object({ export const extractedAssetSchema = z.object({ ip: z.string().nullish(), hostname: z.string().nullish(), + macAddress: z.string().nullish(), + serialNumber: z.string().nullish(), }); export const extractSchema = z.object({ @@ -60,13 +64,14 @@ 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 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: a device-label identifier (distinct from a CPE) +- 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") @@ -74,14 +79,18 @@ FOR DEVICE GROUPS: A device group is a class of affected product, identified by 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 (IPv4 or IPv6) -- hostname +- 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 Entities explicitly referenced in the notification or its attachments. diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 2bb816c0..3e15f011 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -7,12 +7,15 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { z } from "zod"; import type { ConfidenceLevel } from "@/generated/prisma"; import prisma from "@/lib/db"; +import { resolveMatchingId } from "@/lib/router-utils"; import { addProductAlias, addVendorAlias, + enrichAssetIdentifiers, enrichDeviceGroupIdentifiers, - resolveMatchingId, -} from "@/lib/router-utils"; + enrichVulnerabilityCvss, + parseCvssScore, +} from "../utils"; import type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -20,13 +23,26 @@ 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({ @@ -42,7 +58,7 @@ const decisionSchema = z.object({ // 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({ @@ -69,15 +85,17 @@ For each extracted device group, choose exactly ONE action: 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). Set targetId to that candidate's id. +- "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 CVE id, same IP/hostname, 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 extracted entities.`; @@ -127,7 +145,7 @@ function renderCandidates(candidates: Candidates): string { ? entry.matches .map( (m) => - ` - id: ${m.id} | cveId: ${m.cveId ?? "(none)"} | description: ${m.description} ?? "(none)"}`, + ` - id: ${m.id} | cveId: ${m.cveId ?? "(none)"} | description: ${m.description} ?? "(none)"} | cvssScore: ${m.cvssScore ?? "(none)"} | cvssVector: ${m.cvssVector ?? "(none)"}`, ) .join("\n") : " - (no candidates found)"; @@ -167,13 +185,13 @@ function renderCandidates(candidates: Candidates): string { candidates.assets .map((entry, i) => { const e = entry.extracted; - const line = `Asset #${i + 1}: ip=${e.ip ?? "?"} | hostname=${e.hostname} ?? "?"}`; + 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)"}`, + ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname} ?? "(none)"} | macAddress: ${m.macAddress} ?? "(none)"} | serialNumber=${e.serialNumber} ?? "(none)"}`, ) .join("\n") : " - (no candidates found)"; @@ -197,20 +215,41 @@ function cleanFields(fields: Decision["fields"]): { 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 [ "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; } @@ -418,7 +457,22 @@ export async function applyDecisions( }, update: { confidence, reasonWhy: decision.reasonWhy }, }); - summary.linked++; + const data = cleanFields(decision.fields); + if (decision.op === "update" && data.description) { + await tx.vulnerability.update({ + where: { id: decision.targetId }, + data: { description: data.description }, + }); + } + const cvssScore = parseCvssScore(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 || @@ -442,7 +496,18 @@ export async function applyDecisions( }, update: { confidence, reasonWhy: decision.reasonWhy }, }); - summary.linked++; + if (decision.op === "update") { + const data = cleanFields(decision.fields); + if (data.description) { + await tx.remediation.update({ + where: { id: decision.targetId }, + data: { description: data.description }, + }); + } + summary.updated++; + } else { + summary.linked++; + } } else if (decision.kind === "asset") { if (!decision.targetId || !validIds.asset.has(decision.targetId)) { summary.skipped++; @@ -463,6 +528,13 @@ export async function applyDecisions( }, 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/utils.ts b/src/features/inbox/utils.ts index cfce61cb..e6eec42e 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,141 @@ 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 } }, + }); +} + +export function parseCvssScore(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 20 + ? parsed + : undefined; +} diff --git a/src/lib/router-utils.ts b/src/lib/router-utils.ts index e9fd6242..32ac189c 100644 --- a/src/lib/router-utils.ts +++ b/src/lib/router-utils.ts @@ -200,49 +200,6 @@ export async function resolveVersion( } } -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 } }, - }); -} - // ============================================================================ // DeviceGroup resolution // ============================================================================ @@ -326,42 +283,6 @@ export async function resolveDeviceGroup(identity: DeviceGroupIdentityInput) { return deviceGroup; } -// This is similiar to the above 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 } : {}), - }, - }); -} - const CPE_UNKNOWN_TOKENS = new Set(["", "-", "*"]); /** From 5c6c79a04e49ff6c96bd3e34d44b3d656d46594d Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 22:18:03 -0400 Subject: [PATCH 13/15] fix typo --- src/features/inbox/agent/extract.ts | 2 +- src/features/inbox/agent/match.ts | 7 +++---- src/features/inbox/utils.ts | 7 ------- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/features/inbox/agent/extract.ts b/src/features/inbox/agent/extract.ts index 73cb7e26..a24ee8a5 100644 --- a/src/features/inbox/agent/extract.ts +++ b/src/features/inbox/agent/extract.ts @@ -28,7 +28,7 @@ export const extractedVulnerabilitySchema = z.object({ .string() .regex(/^CVE-\d{4}-\d{4,}$/i) .nullish(), - cvssScore: z.number().min(0).max(10).nullish, + cvssScore: z.number().min(0).max(10).nullish(), cvssVector: z.string().nullish(), }); diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 3e15f011..125d9093 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -14,7 +14,6 @@ import { enrichAssetIdentifiers, enrichDeviceGroupIdentifiers, enrichVulnerabilityCvss, - parseCvssScore, } from "../utils"; import type { Candidates } from "./candidate-search"; import type { ExtractResult } from "./extract"; @@ -185,13 +184,13 @@ function renderCandidates(candidates: Candidates): string { 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 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)"}`, + ` - id: ${m.id} | ip: ${m.ip ?? "(none)"} | hostname: ${m.hostname ?? "(none)"} | macAddress: ${m.macAddress ?? "(none)"} | serialNumber=${e.serialNumber ?? "(none)"}`, ) .join("\n") : " - (no candidates found)"; @@ -464,7 +463,7 @@ export async function applyDecisions( data: { description: data.description }, }); } - const cvssScore = parseCvssScore(data.cvssScore); + const cvssScore = data.cvssScore; if (cvssScore !== null || data.cvssVector) { await enrichVulnerabilityCvss(decision.targetId, { cvssScore, diff --git a/src/features/inbox/utils.ts b/src/features/inbox/utils.ts index e6eec42e..fe78cc11 100644 --- a/src/features/inbox/utils.ts +++ b/src/features/inbox/utils.ts @@ -172,10 +172,3 @@ export async function addProductAlias( }); } -export function parseCvssScore(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 && parsed <= 20 - ? parsed - : undefined; -} From f5d72338349cf6f0a65255d70c7943555c83f10b Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 22:33:17 -0400 Subject: [PATCH 14/15] remove extra line --- src/features/inbox/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/inbox/utils.ts b/src/features/inbox/utils.ts index fe78cc11..55dd74e2 100644 --- a/src/features/inbox/utils.ts +++ b/src/features/inbox/utils.ts @@ -170,5 +170,4 @@ export async function addProductAlias( where: { id }, data: { nameMappings: { push: normalized } }, }); -} - +} \ No newline at end of file From c7b0c20989a62ca30002c249a0751253f0d72d23 Mon Sep 17 00:00:00 2001 From: Perry Sy Date: Thu, 2 Jul 2026 22:41:39 -0400 Subject: [PATCH 15/15] npm run format --- src/features/inbox/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/inbox/utils.ts b/src/features/inbox/utils.ts index 55dd74e2..450672fe 100644 --- a/src/features/inbox/utils.ts +++ b/src/features/inbox/utils.ts @@ -170,4 +170,4 @@ export async function addProductAlias( where: { id }, data: { nameMappings: { push: normalized } }, }); -} \ No newline at end of file +}