From 661536161849f505735d81f0b6dccf653f7c4f6a Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 11:40:00 +0100 Subject: [PATCH 01/30] refactor ai flows into server-owned typed service with search agent trace --- src/app/api/ai/cve/[id]/route.ts | 7 +- src/app/api/ai/digest/route.ts | 4 +- src/app/api/ai/search/route.ts | 4 +- src/app/settings/page.tsx | 5 +- src/components/AICveInsightPanel.tsx | 3 +- src/components/AIDigestPanel.tsx | 2 - src/components/AISearchAssistantPanel.tsx | 57 +- src/components/AISettingsPageClient.tsx | 100 +-- src/lib/ai-service.ts | 754 ++++++++++++++++++++++ src/lib/ai-settings.ts | 21 +- src/lib/ai.ts | 437 +------------ src/lib/types.ts | 20 + tests/ai.test.ts | 10 + 13 files changed, 896 insertions(+), 528 deletions(-) create mode 100644 src/lib/ai-service.ts diff --git a/src/app/api/ai/cve/[id]/route.ts b/src/app/api/ai/cve/[id]/route.ts index 1e5abc4..61a6871 100644 --- a/src/app/api/ai/cve/[id]/route.ts +++ b/src/app/api/ai/cve/[id]/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { getCVEByIdServer } from "@/lib/server-api"; -import { generateCveInsight } from "@/lib/ai"; +import { generateCveInsight } from "@/lib/ai-service"; -export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { +export async function POST(_request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; - const body = await request.json().catch(() => null); const detail = await getCVEByIdServer(decodeURIComponent(id)); - const insight = await generateCveInsight(detail, body?.settings); + const insight = await generateCveInsight(detail); return NextResponse.json(insight); } catch (error) { return NextResponse.json( diff --git a/src/app/api/ai/digest/route.ts b/src/app/api/ai/digest/route.ts index 7f8a16e..6c98601 100644 --- a/src/app/api/ai/digest/route.ts +++ b/src/app/api/ai/digest/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { generateDigest } from "@/lib/ai"; +import { generateDigest } from "@/lib/ai-service"; export async function POST(request: NextRequest) { try { @@ -8,7 +8,7 @@ export async function POST(request: NextRequest) { watchlist: Array.isArray(body?.watchlist) ? body.watchlist : [], alerts: Array.isArray(body?.alerts) ? body.alerts : [], projects: Array.isArray(body?.projects) ? body.projects : [], - }, body?.settings); + }); return NextResponse.json(digest); } catch (error) { diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index d6e6bb1..9ff938f 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { generateSearchInterpretation } from "@/lib/ai"; +import { generateSearchInterpretation } from "@/lib/ai-service"; export async function POST(request: NextRequest) { try { @@ -10,7 +10,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "prompt is required" }, { status: 400 }); } - const interpretation = await generateSearchInterpretation(prompt, body?.settings); + const interpretation = await generateSearchInterpretation(prompt); return NextResponse.json(interpretation); } catch (error) { return NextResponse.json( diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index d77da77..c840e93 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,5 +1,8 @@ import AISettingsPageClient from "@/components/AISettingsPageClient"; +import { getServerAIConfigurationSummary } from "@/lib/ai-service"; export default function SettingsPage() { - return ; + const summary = getServerAIConfigurationSummary(); + + return ; } diff --git a/src/components/AICveInsightPanel.tsx b/src/components/AICveInsightPanel.tsx index 2f9ff4b..9e5692b 100644 --- a/src/components/AICveInsightPanel.tsx +++ b/src/components/AICveInsightPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { AICveInsight } from "@/lib/types"; -import { readAISettings } from "@/lib/ai-settings"; export default function AICveInsightPanel({ cveId }: { cveId: string }) { const [insight, setInsight] = useState(null); @@ -20,7 +19,7 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { const res = await fetch(`/api/ai/cve/${encodeURIComponent(cveId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings: readAISettings() }), + body: JSON.stringify({}), }); const data = await res.json().catch(() => null); if (!res.ok) { diff --git a/src/components/AIDigestPanel.tsx b/src/components/AIDigestPanel.tsx index af000ce..05868b6 100644 --- a/src/components/AIDigestPanel.tsx +++ b/src/components/AIDigestPanel.tsx @@ -7,7 +7,6 @@ import { readAlertRules } from "@/lib/alerts"; import { listProjectsAPI } from "@/lib/projects-api"; import { getLatestCVEs } from "@/lib/api"; import { applySearchResultPreferences, matchesSearchState } from "@/lib/search"; -import { readAISettings } from "@/lib/ai-settings"; export default function AIDigestPanel() { const [digest, setDigest] = useState(null); @@ -43,7 +42,6 @@ export default function AIDigestPanel() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - settings: readAISettings(), watchlist: readWatchlist().map((id) => ({ id })), alerts: alertPayload, projects: projects.map((project) => ({ diff --git a/src/components/AISearchAssistantPanel.tsx b/src/components/AISearchAssistantPanel.tsx index d40a5e9..be607da 100644 --- a/src/components/AISearchAssistantPanel.tsx +++ b/src/components/AISearchAssistantPanel.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { SearchState } from "@/lib/search"; -import { readAISettings } from "@/lib/ai-settings"; +import { AISearchInterpretation } from "@/lib/types"; interface AISearchAssistantPanelProps { onApply: (next: Partial) => void; @@ -12,17 +12,19 @@ export default function AISearchAssistantPanel({ onApply }: AISearchAssistantPan const [prompt, setPrompt] = useState(""); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(""); + const [result, setResult] = useState(null); async function handleInterpret() { if (!prompt.trim()) return; setLoading(true); setMessage(""); + setResult(null); try { const res = await fetch("/api/ai/search", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt, settings: readAISettings() }), + body: JSON.stringify({ prompt }), }); const data = await res.json(); if (!res.ok) { @@ -31,6 +33,7 @@ export default function AISearchAssistantPanel({ onApply }: AISearchAssistantPan onApply(data); setMessage(data.explanation || "Applied AI-generated filters."); + setResult(data); } catch (error) { setMessage(error instanceof Error ? error.message : "Failed to interpret search"); } finally { @@ -66,6 +69,56 @@ export default function AISearchAssistantPanel({ onApply }: AISearchAssistantPan + + {result && ( +
+ {result.needsClarification && result.clarificationQuestion ? ( +
+ {result.clarificationQuestion} +
+ ) : null} + +
+

Applied Filters

+
+ {result.appliedFilters.length > 0 ? ( + result.appliedFilters.map((filter) => ( + + {filter.field}: {filter.value} + + )) + ) : ( + No additional filters were applied. + )} +
+
+ + {result.assumptions.length > 0 ? ( +
+

Assumptions

+
    + {result.assumptions.map((assumption) => ( +
  • + {assumption} +
  • + ))} +
+
+ ) : null} + +
+

Agent Trace

+
    + {result.toolCalls.map((call) => ( +
  • + {call.tool} + — {call.summary} +
  • + ))} +
+
+
+ )} ); } diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 5b60490..5a29282 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -1,38 +1,13 @@ -"use client"; - -import { useState } from "react"; import Link from "next/link"; -import { AIProvider, AISettings } from "@/lib/types"; -import { getDefaultAISettings, readAISettings, writeAISettings } from "@/lib/ai-settings"; - -export default function AISettingsPageClient() { - const [settings, setSettings] = useState(() => { - const storedSettings = readAISettings(); - return storedSettings.provider === "heuristic" && !storedSettings.model && !storedSettings.apiKey - ? getDefaultAISettings() - : storedSettings; - }); - const [saved, setSaved] = useState(false); - - function update(key: K, value: AISettings[K]) { - setSettings((current) => ({ - ...current, - [key]: value, - })); - setSaved(false); - } - - function handleSave() { - writeAISettings(settings); - setSaved(true); - } +import { ServerAIConfigurationSummary } from "@/lib/ai-service"; +export default function AISettingsPageClient({ summary }: { summary: ServerAIConfigurationSummary }) { return (

AI Settings

-

Configure provider, model, and API key for browser-local AI features.

+

AI features now use server-side configuration so provider credentials never need to live in the browser.

Back to Search @@ -40,54 +15,37 @@ export default function AISettingsPageClient() {
- - - - - +
+
+ Provider +

{summary.provider}

+
+
+ Mode +

{summary.mode === "configured" ? "Configured provider" : "Heuristic fallback"}

+
+
+ Model +

{summary.model || "Not required in heuristic mode"}

+
+
- These settings are stored in browser local storage. They are not encrypted and are not synced across devices. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. No provider API key is persisted in browser storage. +
+ +
+ {summary.configured + ? `Server-side AI is active using ${summary.provider}${summary.model ? ` (${summary.model})` : ""}.` + : "No server-side AI provider key is configured, so the app is using deterministic heuristic fallbacks."}
- {saved ? "Saved locally." : "Changes are local to this browser."} - + + {summary.availableProviders.length > 0 + ? `Available providers: ${summary.availableProviders.join(", ")}` + : "No model provider credentials detected on the server."} +
diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts new file mode 100644 index 0000000..e0fdd08 --- /dev/null +++ b/src/lib/ai-service.ts @@ -0,0 +1,754 @@ +import { + AICveInsight, + AIDigest, + AIFeature, + AIProvider, + AISearchAppliedFilter, + AISearchFilterField, + AISearchInterpretation, + AISearchToolTrace, + CVEDetail, + ProjectRecord, + SearchSeverityFilter, + SearchSortOption, +} from "./types"; +import { SearchState, normalizeSearchState } from "./search"; +import { extractCVEId, extractDescription, getSeverityFromScore } from "./utils"; + +const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; +const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"; +const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; +const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; +const SEARCH_DEFAULT_SORT: SearchSortOption = "published_desc"; +const SEARCH_DEFAULT_MIN_SEVERITY: SearchSeverityFilter = "ANY"; + +export interface DigestInput { + watchlist: Array<{ id: string; summary?: string; severity?: string }>; + alerts: Array<{ name: string; unread: number; topMatches: string[] }>; + projects: Pick[]; +} + +export interface ServerAIConfigurationSummary { + provider: AIProvider; + model: string; + mode: "heuristic" | "configured"; + configured: boolean; + availableProviders: AIProvider[]; +} + +interface AIRuntimeSettings { + provider: AIProvider; + model: string; + apiKey: string; + mode: "heuristic" | "configured"; +} + +interface StructuredTask { + feature: AIFeature; + prompt: string; + fallback: () => T; + sanitize: (value: unknown) => T; +} + +interface SearchToolContext { + prompt: string; + lower: string; +} + +interface SearchFilterCatalog { + fields: AISearchFilterField[]; + minSeverity: SearchSeverityFilter[]; + sort: SearchSortOption[]; +} + +interface ExtractedPromptSignals { + query: string; + cwe: string; + minSeverity: SearchSeverityFilter; + sort: SearchSortOption; + assumptions: string[]; +} + +interface RelativeTimeSignal { + since: string; + label: string; +} + +interface ClarificationSignal { + needsClarification: boolean; + clarificationQuestion: string; +} + +interface SearchToolOutputs { + catalog: SearchFilterCatalog; + extracted: ExtractedPromptSignals; + time: RelativeTimeSignal; + clarification: ClarificationSignal; +} + +interface SearchPlanningResult { + outputs: SearchToolOutputs; + toolCalls: AISearchToolTrace[]; +} + +export function getServerAIConfigurationSummary(): ServerAIConfigurationSummary { + const runtime = resolveAIRuntime(); + const availableProviders: AIProvider[] = []; + + if (process.env.OPENAI_API_KEY) { + availableProviders.push("openai"); + } + + if (process.env.ANTHROPIC_API_KEY) { + availableProviders.push("anthropic"); + } + + return { + provider: runtime.provider, + model: runtime.model, + mode: runtime.mode, + configured: runtime.mode === "configured", + availableProviders, + }; +} + +export async function generateCveInsight(detail: CVEDetail): Promise { + return executeStructuredTask({ + feature: "cve_insight", + prompt: [ + "You are a security analyst assistant.", + "Return only valid JSON matching this TypeScript shape:", + '{"summary":"string","triage":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","rationale":"string","nextSteps":["string"]},"remediation":["string"],"cluster":{"canonicalId":"string","sourceIds":["string"],"relatedIds":["string"],"summary":"string"}}', + "Base your answer only on this CVE detail JSON:", + JSON.stringify(detail), + ].join("\n"), + fallback: () => buildHeuristicCveInsight(detail), + sanitize: sanitizeInsight, + }); +} + +export async function generateSearchInterpretation(prompt: string): Promise { + const plan = runSearchPlanning(prompt); + const heuristic = buildSearchInterpretationFromPlan(prompt, plan); + const runtime = resolveAIRuntime(); + + if (runtime.mode === "heuristic") { + return heuristic; + } + + try { + const response = await callModel( + [ + "Convert this vulnerability search request into structured filters.", + "Return only valid JSON with keys query, vendor, product, cwe, since, minSeverity, sort, explanation, assumptions, appliedFilters, needsClarification, clarificationQuestion.", + 'Allowed minSeverity: "ANY" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL".', + 'Allowed sort: "published_desc" | "published_asc" | "cvss_desc" | "cvss_asc".', + "Tool outputs:", + JSON.stringify(plan.outputs), + `Request: ${prompt}`, + ].join("\n"), + runtime, + "search_assistant" + ); + + const parsed = sanitizeSearchInterpretation(JSON.parse(response), heuristic); + return { + ...parsed, + toolCalls: plan.toolCalls, + }; + } catch { + return heuristic; + } +} + +export async function generateDigest(input: DigestInput): Promise { + return executeStructuredTask({ + feature: "daily_digest", + prompt: [ + "You are producing a concise vulnerability monitoring digest.", + "Return only valid JSON matching this shape:", + '{"headline":"string","sections":[{"title":"string","body":"string","items":["string"]}]}', + "Use this input JSON:", + JSON.stringify(input), + ].join("\n"), + fallback: () => buildHeuristicDigest(input), + sanitize: sanitizeDigest, + }); +} + +export function buildHeuristicCveInsight(detail: CVEDetail): AICveInsight { + const id = extractCVEId(detail); + const severityScore = detail.cvss3 ?? detail.cvss; + const severity = getSeverityFromScore(severityScore); + const description = extractDescription(detail); + const affected = detail.containers?.cna?.affected?.slice(0, 3) ?? []; + const aliases = detail.aliases ?? []; + const relatedIds = extractRelatedIds(detail); + const priority = severity === "CRITICAL" ? "critical" : severity === "HIGH" ? "high" : severity === "MEDIUM" ? "medium" : "low"; + const status = priority === "critical" || priority === "high" ? "investigating" : "new"; + + return { + summary: `${id} is a ${severity.toLowerCase()} severity vulnerability${affected.length ? ` affecting ${affected.map((item) => item.product || item.vendor).filter(Boolean).join(", ")}` : ""}. ${truncateSentence(description)}`, + triage: { + priority, + status, + rationale: + priority === "critical" || priority === "high" + ? "High severity and affected product exposure suggest immediate analyst review." + : "The record is lower severity or missing severity context, so review is still useful but less urgent.", + nextSteps: [ + "Confirm whether the affected product or version exists in your environment.", + "Review upstream references for patches, advisories, or mitigation guidance.", + "Track ownership and remediation notes in triage before closing the issue.", + ], + }, + remediation: [ + "Identify exposed versions and compare them against vendor-fixed releases.", + "Apply patches or compensating controls where an immediate upgrade is not possible.", + "Validate remediation with version checks, changelog confirmation, or environment-specific testing.", + ], + cluster: { + canonicalId: id, + sourceIds: aliases.filter((alias) => alias !== id), + relatedIds, + summary: + relatedIds.length > 0 + ? "This issue appears alongside linked advisories or aliases and should be reviewed as part of a broader context cluster." + : "This issue currently stands alone in the available alias and linked-vulnerability context.", + }, + }; +} + +export function interpretSearchPromptHeuristically(prompt: string): AISearchInterpretation { + return buildSearchInterpretationFromPlan(prompt, runSearchPlanning(prompt)); +} + +export function buildHeuristicDigest(input: DigestInput): AIDigest { + const highestAlert = [...input.alerts].sort((a, b) => b.unread - a.unread)[0]; + const activeProjects = input.projects.filter((project) => project.items.length > 0); + + return { + headline: + highestAlert && highestAlert.unread > 0 + ? `${highestAlert.unread} unread matches in ${highestAlert.name}` + : `Tracking ${input.watchlist.length} watchlist items across ${activeProjects.length} projects`, + sections: [ + { + title: "Watchlist", + body: `You have ${input.watchlist.length} tracked vulnerabilities in the current profile.`, + items: input.watchlist.slice(0, 5).map((item) => item.id), + }, + { + title: "Alerts", + body: + input.alerts.length > 0 + ? `${input.alerts.filter((item) => item.unread > 0).length} alert rules currently have unread matches.` + : "No alert rules are configured yet.", + items: input.alerts.slice(0, 5).map((item) => `${item.name}: ${item.unread} unread`), + }, + { + title: "Projects", + body: + activeProjects.length > 0 + ? `${activeProjects.length} projects currently contain tracked CVEs.` + : "No projects contain tracked CVEs yet.", + items: activeProjects.slice(0, 5).map((project) => `${project.name}: ${project.items.length} CVEs`), + }, + ], + }; +} + +async function executeStructuredTask({ feature, prompt, fallback, sanitize }: StructuredTask): Promise { + const runtime = resolveAIRuntime(); + if (runtime.mode === "heuristic") { + return fallback(); + } + + try { + const response = await callModel(prompt, runtime, feature); + return sanitize(JSON.parse(response)); + } catch { + return fallback(); + } +} + +function runSearchPlanning(prompt: string): SearchPlanningResult { + const context: SearchToolContext = { + prompt, + lower: prompt.toLowerCase(), + }; + + const catalog = inspectAvailableFilters(context); + const extracted = extractPromptSignals(context); + const time = resolveRelativeTime(context); + const clarification = detectClarificationNeed(context, extracted); + + return { + outputs: { + catalog, + extracted, + time, + clarification, + }, + toolCalls: [ + { tool: "inspect_available_filters", summary: `Available fields: ${catalog.fields.join(", ")}` }, + { + tool: "extract_prompt_signals", + summary: extracted.query + ? `Detected query=${extracted.query}, cwe=${extracted.cwe || "none"}, severity=${extracted.minSeverity}` + : `Detected cwe=${extracted.cwe || "none"}, severity=${extracted.minSeverity}`, + }, + { + tool: "resolve_relative_time", + summary: time.since ? `Resolved ${time.label} to ${time.since}` : "No relative time window detected", + }, + { + tool: "detect_clarification_need", + summary: clarification.needsClarification ? clarification.clarificationQuestion : "No clarification required", + }, + ], + }; +} + +function inspectAvailableFilters(_context: SearchToolContext): SearchFilterCatalog { + return { + fields: ["query", "vendor", "product", "cwe", "since", "minSeverity", "sort"], + minSeverity: ["ANY", "LOW", "MEDIUM", "HIGH", "CRITICAL"], + sort: ["published_desc", "published_asc", "cvss_desc", "cvss_asc"], + }; +} + +function extractPromptSignals(context: SearchToolContext): ExtractedPromptSignals { + const cveMatch = context.prompt.match(/CVE-\d{4}-\d+/i); + const cweMatch = context.prompt.match(/CWE-\d+/i); + const minSeverity = context.lower.includes("critical") + ? "CRITICAL" + : context.lower.includes("high") + ? "HIGH" + : context.lower.includes("medium") + ? "MEDIUM" + : context.lower.includes("low") + ? "LOW" + : "ANY"; + + const query = cveMatch + ? cveMatch[0].toUpperCase() + : context.prompt + .replace(/show me|find|search for|look for|give me|vulns?|vulnerabilities|cves?|that are|affecting|from this week|from this month|this week|this month|today|recent|latest|newly published|published|critical|high|medium|low/gi, " ") + .replace(/\s+/g, " ") + .trim(); + + const assumptions: string[] = []; + if (minSeverity === "ANY") { + assumptions.push("No severity term was stated, so the search keeps the default severity filter."); + } + if (!cweMatch) { + assumptions.push("No explicit CWE identifier was found in the prompt."); + } + if (!query) { + assumptions.push("No product or keyword was extracted, so the search relies on filters only."); + } + + return { + query, + cwe: cweMatch?.[0].toUpperCase() ?? "", + minSeverity, + sort: minSeverity === "ANY" ? SEARCH_DEFAULT_SORT : "cvss_desc", + assumptions, + }; +} + +function resolveRelativeTime(context: SearchToolContext): RelativeTimeSignal { + if (context.lower.includes("today")) { + return { since: isoDateDaysAgo(1), label: "today" }; + } + + if (context.lower.includes("this week")) { + return { since: isoDateDaysAgo(7), label: "this week" }; + } + + if (context.lower.includes("this month")) { + return { since: isoDateDaysAgo(30), label: "this month" }; + } + + return { + since: "", + label: "", + }; +} + +function detectClarificationNeed(context: SearchToolContext, extracted: ExtractedPromptSignals): ClarificationSignal { + const trimmed = context.prompt.trim(); + if (trimmed.length < 8) { + return { + needsClarification: true, + clarificationQuestion: "What product, vendor, or CVE family do you want to search for?", + }; + } + + if (!extracted.query && extracted.minSeverity === "ANY" && !extracted.cwe) { + return { + needsClarification: true, + clarificationQuestion: "Do you want to narrow this by product, vendor, severity, or time window?", + }; + } + + return { + needsClarification: false, + clarificationQuestion: "", + }; +} + +function buildSearchInterpretationFromPlan(prompt: string, plan: SearchPlanningResult): AISearchInterpretation { + const normalized = normalizeSearchState({ + query: plan.outputs.extracted.query, + vendor: "", + product: "", + cwe: plan.outputs.extracted.cwe, + since: plan.outputs.time.since, + minSeverity: plan.outputs.extracted.minSeverity, + sort: plan.outputs.extracted.sort, + }); + + return { + query: normalized.query, + vendor: normalized.vendor, + product: normalized.product, + cwe: normalized.cwe, + since: normalized.since, + minSeverity: normalized.minSeverity, + sort: normalized.sort, + explanation: buildSearchExplanation(prompt, normalized), + assumptions: plan.outputs.extracted.assumptions, + appliedFilters: buildAppliedFilters(normalized), + toolCalls: plan.toolCalls, + needsClarification: plan.outputs.clarification.needsClarification, + clarificationQuestion: plan.outputs.clarification.clarificationQuestion, + }; +} + +function buildSearchExplanation(prompt: string, normalized: SearchState): string { + const applied: string[] = []; + if (normalized.query) applied.push(`query=${normalized.query}`); + if (normalized.cwe) applied.push(`cwe=${normalized.cwe}`); + if (normalized.since) applied.push(`since=${normalized.since}`); + if (normalized.minSeverity !== SEARCH_DEFAULT_MIN_SEVERITY) applied.push(`minSeverity=${normalized.minSeverity}`); + if (normalized.sort !== SEARCH_DEFAULT_SORT) applied.push(`sort=${normalized.sort}`); + + if (applied.length === 0) { + return `Reviewed \"${prompt.trim()}\" and kept the default search settings because no stronger filter signal was detected.`; + } + + return `Reviewed \"${prompt.trim()}\" and applied ${applied.join(", ")}.`; +} + +function buildAppliedFilters(state: SearchState): AISearchAppliedFilter[] { + const filters: Array<[AISearchFilterField, string, string]> = [ + ["query", state.query, "Keyword or identifier extracted from the request."], + ["vendor", state.vendor, "Vendor filter inferred from the request."], + ["product", state.product, "Product filter inferred from the request."], + ["cwe", state.cwe, "CWE identifier extracted from the request."], + ["since", state.since, "Relative time window resolved into an ISO date."], + ["minSeverity", state.minSeverity !== SEARCH_DEFAULT_MIN_SEVERITY ? state.minSeverity : "", "Severity term detected in the request."], + ["sort", state.sort !== SEARCH_DEFAULT_SORT ? state.sort : "", "Sort order adjusted to match the risk signal in the request."], + ]; + + return filters.flatMap(([field, value, reason]) => + value + ? [ + { + field, + value, + reason, + }, + ] + : [] + ); +} + +async function callModel(prompt: string, settings: AIRuntimeSettings, feature: AIFeature): Promise { + if (settings.provider === "anthropic") { + return callAnthropic(prompt, settings, feature); + } + + return callOpenAI(prompt, settings, feature); +} + +async function callOpenAI(prompt: string, settings: AIRuntimeSettings, feature: AIFeature): Promise { + const res = await fetch(OPENAI_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.apiKey}`, + }, + body: JSON.stringify({ + model: settings.model || DEFAULT_OPENAI_MODEL, + temperature: feature === "daily_digest" ? 0.3 : 0.2, + messages: [ + { + role: "system", + content: "Return only JSON. No markdown. No prose outside JSON.", + }, + { + role: "user", + content: prompt, + }, + ], + }), + }); + + if (!res.ok) { + throw new Error(`OpenAI error: ${res.status}`); + } + + const data = await res.json(); + const content = data?.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content.trim()) { + throw new Error("OpenAI response did not include content"); + } + + return content; +} + +async function callAnthropic(prompt: string, settings: AIRuntimeSettings, feature: AIFeature): Promise { + const res = await fetch(ANTHROPIC_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": settings.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: settings.model || DEFAULT_ANTHROPIC_MODEL, + max_tokens: feature === "daily_digest" ? 1200 : 800, + temperature: feature === "daily_digest" ? 0.3 : 0.2, + messages: [ + { + role: "user", + content: `Return only JSON. No markdown. No prose outside JSON.\n\n${prompt}`, + }, + ], + }), + }); + + if (!res.ok) { + throw new Error(`Anthropic error: ${res.status}`); + } + + const data = await res.json(); + const content = data?.content?.find?.((item: { type?: string }) => item.type === "text")?.text; + if (typeof content !== "string" || !content.trim()) { + throw new Error("Anthropic response did not include content"); + } + + return content; +} + +function resolveAIRuntime(): AIRuntimeSettings { + const requestedProvider = normalizeProvider(process.env.AI_PROVIDER); + const openAIKey = process.env.OPENAI_API_KEY?.trim() ?? ""; + const anthropicKey = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; + + if (requestedProvider === "openai" && openAIKey) { + return { + provider: "openai", + model: (process.env.OPENAI_MODEL ?? DEFAULT_OPENAI_MODEL).trim(), + apiKey: openAIKey, + mode: "configured", + }; + } + + if (requestedProvider === "anthropic" && anthropicKey) { + return { + provider: "anthropic", + model: (process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC_MODEL).trim(), + apiKey: anthropicKey, + mode: "configured", + }; + } + + if (openAIKey) { + return { + provider: "openai", + model: (process.env.OPENAI_MODEL ?? DEFAULT_OPENAI_MODEL).trim(), + apiKey: openAIKey, + mode: "configured", + }; + } + + if (anthropicKey) { + return { + provider: "anthropic", + model: (process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC_MODEL).trim(), + apiKey: anthropicKey, + mode: "configured", + }; + } + + return { + provider: "heuristic", + model: "", + apiKey: "", + mode: "heuristic", + }; +} + +function normalizeProvider(value: string | undefined): AIProvider | undefined { + if (value === "openai" || value === "anthropic" || value === "heuristic") { + return value; + } + + return undefined; +} + +function sanitizeInsight(value: unknown): AICveInsight { + const fallback = buildHeuristicCveInsight({ id: "CVE-UNKNOWN" }); + if (!value || typeof value !== "object") return fallback; + const record = value as Record; + + return { + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + triage: sanitizeTriage(record.triage, fallback.triage), + remediation: Array.isArray(record.remediation) ? record.remediation.filter((item): item is string => typeof item === "string").slice(0, 6) : fallback.remediation, + cluster: sanitizeCluster(record.cluster, fallback.cluster), + }; +} + +function sanitizeSearchInterpretation(value: unknown, fallback = interpretSearchPromptHeuristically("")): AISearchInterpretation { + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + const normalized = normalizeSearchState(record as Partial); + return { + query: normalized.query, + vendor: normalized.vendor, + product: normalized.product, + cwe: normalized.cwe, + since: normalized.since, + minSeverity: normalized.minSeverity, + sort: normalized.sort, + explanation: typeof record.explanation === "string" ? record.explanation : fallback.explanation, + assumptions: Array.isArray(record.assumptions) + ? record.assumptions.filter((item): item is string => typeof item === "string").slice(0, 8) + : fallback.assumptions, + appliedFilters: Array.isArray(record.appliedFilters) + ? record.appliedFilters + .filter((item): item is Record => Boolean(item) && typeof item === "object") + .flatMap((item) => { + const field = item.field; + const value = item.value; + const reason = item.reason; + return isSearchFilterField(field) && typeof value === "string" && typeof reason === "string" + ? [ + { + field, + value, + reason, + }, + ] + : []; + }) + : fallback.appliedFilters, + toolCalls: fallback.toolCalls, + needsClarification: typeof record.needsClarification === "boolean" ? record.needsClarification : fallback.needsClarification, + clarificationQuestion: + typeof record.clarificationQuestion === "string" ? record.clarificationQuestion : fallback.clarificationQuestion, + }; +} + +function sanitizeDigest(value: unknown): AIDigest { + if (!value || typeof value !== "object") { + return buildHeuristicDigest({ watchlist: [], alerts: [], projects: [] }); + } + + const record = value as Record; + return { + headline: typeof record.headline === "string" ? record.headline : "AI digest", + sections: Array.isArray(record.sections) + ? record.sections + .filter((section): section is Record => Boolean(section) && typeof section === "object") + .map((section) => ({ + title: typeof section.title === "string" ? section.title : "Section", + body: typeof section.body === "string" ? section.body : "", + items: Array.isArray(section.items) + ? section.items.filter((item): item is string => typeof item === "string").slice(0, 8) + : [], + })) + : [], + }; +} + +function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICveInsight["triage"] { + if (!value || typeof value !== "object") return fallback; + const record = value as Record; + return { + priority: + record.priority === "critical" || record.priority === "high" || record.priority === "medium" || record.priority === "low" + ? record.priority + : fallback.priority, + status: + record.status === "new" || record.status === "investigating" || record.status === "mitigated" || record.status === "accepted" || record.status === "closed" + ? record.status + : fallback.status, + rationale: typeof record.rationale === "string" ? record.rationale : fallback.rationale, + nextSteps: Array.isArray(record.nextSteps) + ? record.nextSteps.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.nextSteps, + }; +} + +function sanitizeCluster(value: unknown, fallback: AICveInsight["cluster"]): AICveInsight["cluster"] { + if (!value || typeof value !== "object") return fallback; + const record = value as Record; + return { + canonicalId: typeof record.canonicalId === "string" ? record.canonicalId : fallback.canonicalId, + sourceIds: Array.isArray(record.sourceIds) ? record.sourceIds.filter((item): item is string => typeof item === "string").slice(0, 10) : fallback.sourceIds, + relatedIds: Array.isArray(record.relatedIds) ? record.relatedIds.filter((item): item is string => typeof item === "string").slice(0, 10) : fallback.relatedIds, + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + }; +} + +function isSearchFilterField(value: unknown): value is AISearchFilterField { + return value === "query" || value === "vendor" || value === "product" || value === "cwe" || value === "since" || value === "minSeverity" || value === "sort"; +} + +function extractRelatedIds(detail: CVEDetail): string[] { + const related = new Set(); + for (const alias of detail.aliases ?? []) { + related.add(alias); + } + + const record = detail as unknown as Record; + for (const key of ["linked_vulnerabilities", "related_vulnerabilities", "vulnerabilities", "related"]) { + const value = record[key]; + if (!Array.isArray(value)) continue; + for (const item of value) { + if (typeof item === "string") related.add(item); + if (item && typeof item === "object") { + const obj = item as Record; + for (const field of ["id", "cve", "vulnerability"]) { + if (typeof obj[field] === "string") { + related.add(obj[field] as string); + } + } + } + } + } + + related.delete(extractCVEId(detail)); + return Array.from(related).slice(0, 10); +} + +function truncateSentence(input: string, max = 220): string { + const trimmed = input.trim(); + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max).trim()}...`; +} + +function isoDateDaysAgo(days: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - days); + return date.toISOString().slice(0, 10); +} diff --git a/src/lib/ai-settings.ts b/src/lib/ai-settings.ts index 10a990f..9c77402 100644 --- a/src/lib/ai-settings.ts +++ b/src/lib/ai-settings.ts @@ -1,6 +1,5 @@ import { AISettings, AIProvider } from "./types"; -const AI_SETTINGS_STORAGE_KEY = "cvesearch.ai-settings"; export const AI_SETTINGS_UPDATED_EVENT = "cvesearch:ai-settings-updated"; export function getDefaultAISettings(): AISettings { @@ -12,24 +11,15 @@ export function getDefaultAISettings(): AISettings { } export function readAISettings(): AISettings { - if (typeof window === "undefined") return getDefaultAISettings(); - - try { - const raw = window.localStorage.getItem(AI_SETTINGS_STORAGE_KEY); - if (!raw) return getDefaultAISettings(); - const parsed = JSON.parse(raw); - return normalizeAISettings(parsed); - } catch { - return getDefaultAISettings(); - } + return getDefaultAISettings(); } export function writeAISettings(settings: AISettings): AISettings { const normalized = normalizeAISettings(settings); - if (typeof window === "undefined") return normalized; + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(AI_SETTINGS_UPDATED_EVENT)); + } - window.localStorage.setItem(AI_SETTINGS_STORAGE_KEY, JSON.stringify(normalized)); - window.dispatchEvent(new CustomEvent(AI_SETTINGS_UPDATED_EVENT)); return normalized; } @@ -37,12 +27,11 @@ export function normalizeAISettings(value: unknown): AISettings { const record = value && typeof value === "object" ? (value as Record) : {}; const provider = isProvider(record.provider) ? record.provider : "heuristic"; const model = typeof record.model === "string" ? record.model.trim() : ""; - const apiKey = typeof record.apiKey === "string" ? record.apiKey.trim() : ""; return { provider, model, - apiKey, + apiKey: "", }; } diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 01533ea..c2b1d1a 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,426 +1,11 @@ -import { SearchState, normalizeSearchState } from "./search"; -import { AIProvider, AISettings, CVEDetail, AICveInsight, AIDigest, AISearchInterpretation, ProjectRecord } from "./types"; -import { extractCVEId, extractDescription, getSeverityFromScore } from "./utils"; - -const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; -const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"; -const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; -const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; - -export interface DigestInput { - watchlist: Array<{ id: string; summary?: string; severity?: string }>; - alerts: Array<{ name: string; unread: number; topMatches: string[] }>; - projects: Pick[]; -} - -export async function generateCveInsight(detail: CVEDetail, settings?: Partial): Promise { - const runtime = resolveAISettings(settings); - if (runtime.provider === "heuristic") { - return buildHeuristicCveInsight(detail); - } - - try { - const prompt = [ - "You are a security analyst assistant.", - "Return only valid JSON matching this TypeScript shape:", - '{"summary":"string","triage":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","rationale":"string","nextSteps":["string"]},"remediation":["string"],"cluster":{"canonicalId":"string","sourceIds":["string"],"relatedIds":["string"],"summary":"string"}}', - "Base your answer only on this CVE detail JSON:", - JSON.stringify(detail), - ].join("\n"); - - const response = await callModel(prompt, runtime); - return sanitizeInsight(JSON.parse(response)); - } catch { - return buildHeuristicCveInsight(detail); - } -} - -export async function generateSearchInterpretation(prompt: string, settings?: Partial): Promise { - const runtime = resolveAISettings(settings); - if (runtime.provider === "heuristic") { - return interpretSearchPromptHeuristically(prompt); - } - - try { - const modelPrompt = [ - "Convert this vulnerability search request into filters.", - "Return only valid JSON with keys query, vendor, product, cwe, since, minSeverity, sort, explanation.", - 'Allowed minSeverity: "ANY" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL".', - 'Allowed sort: "published_desc" | "published_asc" | "cvss_desc" | "cvss_asc".', - `Request: ${prompt}`, - ].join("\n"); - - const response = await callModel(modelPrompt, runtime); - return sanitizeSearchInterpretation(JSON.parse(response)); - } catch { - return interpretSearchPromptHeuristically(prompt); - } -} - -export async function generateDigest(input: DigestInput, settings?: Partial): Promise { - const runtime = resolveAISettings(settings); - if (runtime.provider === "heuristic") { - return buildHeuristicDigest(input); - } - - try { - const prompt = [ - "You are producing a concise vulnerability monitoring digest.", - "Return only valid JSON matching this shape:", - '{"headline":"string","sections":[{"title":"string","body":"string","items":["string"]}]}', - "Use this input JSON:", - JSON.stringify(input), - ].join("\n"); - - const response = await callModel(prompt, runtime); - return sanitizeDigest(JSON.parse(response)); - } catch { - return buildHeuristicDigest(input); - } -} - -export function buildHeuristicCveInsight(detail: CVEDetail): AICveInsight { - const id = extractCVEId(detail); - const severityScore = detail.cvss3 ?? detail.cvss; - const severity = getSeverityFromScore(severityScore); - const description = extractDescription(detail); - const affected = detail.containers?.cna?.affected?.slice(0, 3) ?? []; - const aliases = detail.aliases ?? []; - const relatedIds = extractRelatedIds(detail); - const priority = severity === "CRITICAL" ? "critical" : severity === "HIGH" ? "high" : severity === "MEDIUM" ? "medium" : "low"; - const status = priority === "critical" || priority === "high" ? "investigating" : "new"; - - return { - summary: `${id} is a ${severity.toLowerCase()} severity vulnerability${affected.length ? ` affecting ${affected.map((item) => item.product || item.vendor).filter(Boolean).join(", ")}` : ""}. ${truncateSentence(description)}`, - triage: { - priority, - status, - rationale: - priority === "critical" || priority === "high" - ? "High severity and affected product exposure suggest immediate analyst review." - : "The record is lower severity or missing severity context, so review is still useful but less urgent.", - nextSteps: [ - "Confirm whether the affected product or version exists in your environment.", - "Review upstream references for patches, advisories, or mitigation guidance.", - "Track ownership and remediation notes in triage before closing the issue.", - ], - }, - remediation: [ - "Identify exposed versions and compare them against vendor-fixed releases.", - "Apply patches or compensating controls where an immediate upgrade is not possible.", - "Validate remediation with version checks, changelog confirmation, or environment-specific testing.", - ], - cluster: { - canonicalId: id, - sourceIds: aliases.filter((alias) => alias !== id), - relatedIds, - summary: - relatedIds.length > 0 - ? "This issue appears alongside linked advisories or aliases and should be reviewed as part of a broader context cluster." - : "This issue currently stands alone in the available alias and linked-vulnerability context.", - }, - }; -} - -export function interpretSearchPromptHeuristically(prompt: string): AISearchInterpretation { - const lower = prompt.toLowerCase(); - const cveMatch = prompt.match(/CVE-\d{4}-\d+/i); - const cweMatch = prompt.match(/CWE-\d+/i); - const minSeverity = lower.includes("critical") - ? "CRITICAL" - : lower.includes("high") - ? "HIGH" - : lower.includes("medium") - ? "MEDIUM" - : lower.includes("low") - ? "LOW" - : "ANY"; - const since = lower.includes("this week") - ? isoDateDaysAgo(7) - : lower.includes("today") - ? isoDateDaysAgo(1) - : lower.includes("this month") - ? isoDateDaysAgo(30) - : ""; - - const cleanedQuery = cveMatch - ? cveMatch[0].toUpperCase() - : prompt - .replace(/show me|find|search for|vulns?|vulnerabilities|from this week|from this month|today|critical|high|medium|low/gi, " ") - .trim() - .replace(/\s+/g, " "); - - return sanitizeSearchInterpretation({ - query: cleanedQuery, - vendor: "", - product: "", - cwe: cweMatch?.[0].toUpperCase() ?? "", - since, - minSeverity, - sort: minSeverity === "ANY" ? "published_desc" : "cvss_desc", - explanation: "Interpreted your natural-language search into query, severity, and time filters.", - }); -} - -export function buildHeuristicDigest(input: DigestInput): AIDigest { - const highestAlert = [...input.alerts].sort((a, b) => b.unread - a.unread)[0]; - const activeProjects = input.projects.filter((project) => project.items.length > 0); - - return { - headline: - highestAlert && highestAlert.unread > 0 - ? `${highestAlert.unread} unread matches in ${highestAlert.name}` - : `Tracking ${input.watchlist.length} watchlist items across ${activeProjects.length} projects`, - sections: [ - { - title: "Watchlist", - body: `You have ${input.watchlist.length} tracked vulnerabilities in the current browser profile.`, - items: input.watchlist.slice(0, 5).map((item) => item.id), - }, - { - title: "Alerts", - body: - input.alerts.length > 0 - ? `${input.alerts.filter((item) => item.unread > 0).length} alert rules currently have unread matches.` - : "No alert rules are configured yet.", - items: input.alerts.slice(0, 5).map((item) => `${item.name}: ${item.unread} unread`), - }, - { - title: "Projects", - body: - activeProjects.length > 0 - ? `${activeProjects.length} projects currently contain tracked CVEs.` - : "No projects contain tracked CVEs yet.", - items: activeProjects.slice(0, 5).map((project) => `${project.name}: ${project.items.length} CVEs`), - }, - ], - }; -} - -async function callModel(prompt: string, settings: AISettings): Promise { - if (settings.provider === "anthropic") { - return callAnthropic(prompt, settings); - } - return callOpenAI(prompt, settings); -} - -async function callOpenAI(prompt: string, settings: AISettings): Promise { - const res = await fetch(OPENAI_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${settings.apiKey}`, - }, - body: JSON.stringify({ - model: settings.model || DEFAULT_OPENAI_MODEL, - temperature: 0.2, - messages: [ - { - role: "system", - content: "Return only JSON. No markdown. No prose outside JSON.", - }, - { - role: "user", - content: prompt, - }, - ], - }), - }); - - if (!res.ok) { - throw new Error(`OpenAI error: ${res.status}`); - } - - const data = await res.json(); - const content = data?.choices?.[0]?.message?.content; - if (typeof content !== "string" || !content.trim()) { - throw new Error("OpenAI response did not include content"); - } - - return content; -} - -async function callAnthropic(prompt: string, settings: AISettings): Promise { - const res = await fetch(ANTHROPIC_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": settings.apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: settings.model || DEFAULT_ANTHROPIC_MODEL, - max_tokens: 800, - temperature: 0.2, - messages: [ - { - role: "user", - content: `Return only JSON. No markdown. No prose outside JSON.\n\n${prompt}`, - }, - ], - }), - }); - - if (!res.ok) { - throw new Error(`Anthropic error: ${res.status}`); - } - - const data = await res.json(); - const content = data?.content?.find?.((item: { type?: string }) => item.type === "text")?.text; - if (typeof content !== "string" || !content.trim()) { - throw new Error("Anthropic response did not include content"); - } - - return content; -} - -function sanitizeInsight(value: unknown): AICveInsight { - const fallback = buildHeuristicCveInsight({ id: "CVE-UNKNOWN" }); - if (!value || typeof value !== "object") return fallback; - const record = value as Record; - - return { - summary: typeof record.summary === "string" ? record.summary : fallback.summary, - triage: sanitizeTriage(record.triage, fallback.triage), - remediation: Array.isArray(record.remediation) ? record.remediation.filter((item): item is string => typeof item === "string").slice(0, 6) : fallback.remediation, - cluster: sanitizeCluster(record.cluster, fallback.cluster), - }; -} - -function sanitizeSearchInterpretation(value: unknown): AISearchInterpretation { - if (!value || typeof value !== "object") { - return interpretSearchPromptHeuristically(""); - } - - const normalized = normalizeSearchState(value as Partial); - return { - query: normalized.query, - vendor: normalized.vendor, - product: normalized.product, - cwe: normalized.cwe, - since: normalized.since, - minSeverity: normalized.minSeverity, - sort: normalized.sort, - explanation: - typeof (value as Record).explanation === "string" - ? ((value as Record).explanation as string) - : "Generated a structured search interpretation.", - }; -} - -function sanitizeDigest(value: unknown): AIDigest { - if (!value || typeof value !== "object") { - return buildHeuristicDigest({ watchlist: [], alerts: [], projects: [] }); - } - - const record = value as Record; - return { - headline: typeof record.headline === "string" ? record.headline : "AI digest", - sections: Array.isArray(record.sections) - ? record.sections - .filter((section): section is Record => Boolean(section) && typeof section === "object") - .map((section) => ({ - title: typeof section.title === "string" ? section.title : "Section", - body: typeof section.body === "string" ? section.body : "", - items: Array.isArray(section.items) - ? section.items.filter((item): item is string => typeof item === "string").slice(0, 8) - : [], - })) - : [], - }; -} - -function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICveInsight["triage"] { - if (!value || typeof value !== "object") return fallback; - const record = value as Record; - return { - priority: - record.priority === "critical" || record.priority === "high" || record.priority === "medium" || record.priority === "low" - ? record.priority - : fallback.priority, - status: - record.status === "new" || record.status === "investigating" || record.status === "mitigated" || record.status === "accepted" || record.status === "closed" - ? record.status - : fallback.status, - rationale: typeof record.rationale === "string" ? record.rationale : fallback.rationale, - nextSteps: Array.isArray(record.nextSteps) - ? record.nextSteps.filter((item): item is string => typeof item === "string").slice(0, 6) - : fallback.nextSteps, - }; -} - -function sanitizeCluster(value: unknown, fallback: AICveInsight["cluster"]): AICveInsight["cluster"] { - if (!value || typeof value !== "object") return fallback; - const record = value as Record; - return { - canonicalId: typeof record.canonicalId === "string" ? record.canonicalId : fallback.canonicalId, - sourceIds: Array.isArray(record.sourceIds) ? record.sourceIds.filter((item): item is string => typeof item === "string").slice(0, 10) : fallback.sourceIds, - relatedIds: Array.isArray(record.relatedIds) ? record.relatedIds.filter((item): item is string => typeof item === "string").slice(0, 10) : fallback.relatedIds, - summary: typeof record.summary === "string" ? record.summary : fallback.summary, - }; -} - -function extractRelatedIds(detail: CVEDetail): string[] { - const related = new Set(); - for (const alias of detail.aliases ?? []) { - related.add(alias); - } - - const record = detail as unknown as Record; - for (const key of ["linked_vulnerabilities", "related_vulnerabilities", "vulnerabilities", "related"]) { - const value = record[key]; - if (!Array.isArray(value)) continue; - for (const item of value) { - if (typeof item === "string") related.add(item); - if (item && typeof item === "object") { - const obj = item as Record; - for (const field of ["id", "cve", "vulnerability"]) { - if (typeof obj[field] === "string") { - related.add(obj[field] as string); - } - } - } - } - } - - related.delete(extractCVEId(detail)); - return Array.from(related).slice(0, 10); -} - -function truncateSentence(input: string, max = 220): string { - const trimmed = input.trim(); - if (trimmed.length <= max) return trimmed; - return `${trimmed.slice(0, max).trim()}...`; -} - -function isoDateDaysAgo(days: number): string { - const date = new Date(); - date.setUTCDate(date.getUTCDate() - days); - return date.toISOString().slice(0, 10); -} - -function resolveAISettings(settings?: Partial): AISettings { - const provider = settings?.provider ?? (process.env.OPENAI_API_KEY ? "openai" : "heuristic"); - const apiKey = - settings?.apiKey ?? - (provider === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY) ?? - ""; - const model = - settings?.model ?? - (provider === "anthropic" ? process.env.ANTHROPIC_MODEL : process.env.OPENAI_MODEL) ?? - ""; - - if (provider !== "heuristic" && !apiKey) { - return { - provider: "heuristic", - model: "", - apiKey: "", - }; - } - - return { - provider: provider as AIProvider, - model, - apiKey, - }; -} +export { + buildHeuristicCveInsight, + buildHeuristicDigest, + generateCveInsight, + generateDigest, + generateSearchInterpretation, + getServerAIConfigurationSummary, + interpretSearchPromptHeuristically, +} from "./ai-service"; + +export type { DigestInput, ServerAIConfigurationSummary } from "./ai-service"; diff --git a/src/lib/types.ts b/src/lib/types.ts index d4c8568..27ea116 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -203,6 +203,19 @@ export interface AICveInsight { cluster: AIContextCluster; } +export type AISearchFilterField = "query" | "vendor" | "product" | "cwe" | "since" | "minSeverity" | "sort"; + +export interface AISearchAppliedFilter { + field: AISearchFilterField; + value: string; + reason: string; +} + +export interface AISearchToolTrace { + tool: string; + summary: string; +} + export interface AISearchInterpretation { query: string; vendor: string; @@ -212,6 +225,11 @@ export interface AISearchInterpretation { minSeverity: SearchSeverityFilter; sort: SearchSortOption; explanation: string; + assumptions: string[]; + appliedFilters: AISearchAppliedFilter[]; + toolCalls: AISearchToolTrace[]; + needsClarification: boolean; + clarificationQuestion: string; } export interface AIDigestSection { @@ -227,6 +245,8 @@ export interface AIDigest { export type AIProvider = "heuristic" | "openai" | "anthropic"; +export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest"; + export interface AISettings { provider: AIProvider; model: string; diff --git a/tests/ai.test.ts b/tests/ai.test.ts index f7f7300..0df92b6 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -13,6 +13,16 @@ test("interpretSearchPromptHeuristically extracts severity and recent window", ( assert.equal(result.sort, "cvss_desc"); assert.match(result.query, /openssl/i); assert.notEqual(result.since, ""); + assert.equal(result.appliedFilters.some((filter) => filter.field === "query"), true); + assert.equal(result.toolCalls.length > 0, true); + assert.equal(result.needsClarification, false); +}); + +test("interpretSearchPromptHeuristically requests clarification for underspecified prompts", () => { + const result = interpretSearchPromptHeuristically("recent"); + + assert.equal(result.needsClarification, true); + assert.match(result.clarificationQuestion, /product|vendor|severity|time window/i); }); test("buildHeuristicCveInsight produces triage and remediation guidance", () => { From ab75f9b0066410f7c2a55c861d2b1a64b9f97d31 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 11:57:56 +0100 Subject: [PATCH 02/30] feat(ai): add context-aware cve triage agent with epss and project signals --- src/app/api/ai/cve/[id]/route.ts | 19 +- src/components/AICveInsightPanel.tsx | 37 +++- src/lib/ai-service.ts | 257 +++++++++++++++++++++++++-- src/lib/server-api.ts | 13 +- src/lib/types.ts | 25 +++ tests/ai.test.ts | 48 +++++ 6 files changed, 379 insertions(+), 20 deletions(-) diff --git a/src/app/api/ai/cve/[id]/route.ts b/src/app/api/ai/cve/[id]/route.ts index 61a6871..ba21dfe 100644 --- a/src/app/api/ai/cve/[id]/route.ts +++ b/src/app/api/ai/cve/[id]/route.ts @@ -1,12 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; -import { getCVEByIdServer } from "@/lib/server-api"; +import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; +import { listProjects } from "@/lib/projects-store"; import { generateCveInsight } from "@/lib/ai-service"; -export async function POST(_request: NextRequest, context: { params: Promise<{ id: string }> }) { +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; + const body = await request.json().catch(() => null); const detail = await getCVEByIdServer(decodeURIComponent(id)); - const insight = await generateCveInsight(detail); + const [epss, projects] = await Promise.all([getEPSSServer(detail.id), listProjects()]); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const insight = await generateCveInsight({ + detail, + epss, + triage: body?.triage && typeof body.triage === "object" ? body.triage : null, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); return NextResponse.json(insight); } catch (error) { return NextResponse.json( diff --git a/src/components/AICveInsightPanel.tsx b/src/components/AICveInsightPanel.tsx index 9e5692b..f97b6cb 100644 --- a/src/components/AICveInsightPanel.tsx +++ b/src/components/AICveInsightPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { AICveInsight } from "@/lib/types"; +import { readTriageRecord, TRIAGE_UPDATED_EVENT } from "@/lib/triage"; export default function AICveInsightPanel({ cveId }: { cveId: string }) { const [insight, setInsight] = useState(null); @@ -19,7 +20,7 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { const res = await fetch(`/api/ai/cve/${encodeURIComponent(cveId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ triage: readTriageRecord(cveId) }), }); const data = await res.json().catch(() => null); if (!res.ok) { @@ -41,8 +42,10 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { } load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); return () => { cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); }; }, [cveId]); @@ -72,8 +75,26 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { {insight.triage.status} + + confidence: {insight.triage.confidence} +

{insight.triage.rationale}

+
+ Owner recommendation: {insight.triage.ownerRecommendation} +
+
+ {insight.triage.signals.map((signal) => ( +
+
+ {signal.label} + {signal.level} +
+

{signal.value}

+

{signal.rationale}

+
+ ))} +
    {insight.triage.nextSteps.map((step) => (
  • @@ -83,6 +104,20 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) {
+
+

Project Context

+

{insight.projectContext.summary}

+ {insight.projectContext.projectNames.length > 0 ? ( +
+ {insight.projectContext.projectNames.map((project) => ( + + Project: {project} + + ))} +
+ ) : null} +
+

Remediation Notes

    diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index e0fdd08..a6f0466 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -2,12 +2,15 @@ import { AICveInsight, AIDigest, AIFeature, + AITriageContextSnapshot, + AITriageSignal, AIProvider, AISearchAppliedFilter, AISearchFilterField, AISearchInterpretation, AISearchToolTrace, CVEDetail, + EPSSData, ProjectRecord, SearchSeverityFilter, SearchSortOption, @@ -28,6 +31,13 @@ export interface DigestInput { projects: Pick[]; } +export interface CveInsightInput { + detail: CVEDetail; + epss: EPSSData | null; + triage: AITriageContextSnapshot | null; + relatedProjects: Pick[]; +} + export interface ServerAIConfigurationSummary { provider: AIProvider; model: string; @@ -112,17 +122,17 @@ export function getServerAIConfigurationSummary(): ServerAIConfigurationSummary }; } -export async function generateCveInsight(detail: CVEDetail): Promise { +export async function generateCveInsight(input: CveInsightInput): Promise { return executeStructuredTask({ feature: "cve_insight", prompt: [ "You are a security analyst assistant.", "Return only valid JSON matching this TypeScript shape:", - '{"summary":"string","triage":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","rationale":"string","nextSteps":["string"]},"remediation":["string"],"cluster":{"canonicalId":"string","sourceIds":["string"],"relatedIds":["string"],"summary":"string"}}', - "Base your answer only on this CVE detail JSON:", - JSON.stringify(detail), + '{"summary":"string","triage":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","confidence":"high|medium|low","ownerRecommendation":"string","rationale":"string","nextSteps":["string"],"signals":[{"label":"string","value":"string","level":"high|medium|low","rationale":"string"}]},"remediation":["string"],"cluster":{"canonicalId":"string","sourceIds":["string"],"relatedIds":["string"],"summary":"string"},"projectContext":{"projectCount":0,"projectNames":["string"],"summary":"string"}}', + "Base your answer only on this triage input JSON:", + JSON.stringify(input), ].join("\n"), - fallback: () => buildHeuristicCveInsight(detail), + fallback: () => buildHeuristicCveInsight(input), sanitize: sanitizeInsight, }); } @@ -176,7 +186,9 @@ export async function generateDigest(input: DigestInput): Promise { }); } -export function buildHeuristicCveInsight(detail: CVEDetail): AICveInsight { +export function buildHeuristicCveInsight(input: CVEDetail | CveInsightInput): AICveInsight { + const normalized = normalizeCveInsightInput(input); + const { detail, epss, triage, relatedProjects } = normalized; const id = extractCVEId(detail); const severityScore = detail.cvss3 ?? detail.cvss; const severity = getSeverityFromScore(severityScore); @@ -184,27 +196,32 @@ export function buildHeuristicCveInsight(detail: CVEDetail): AICveInsight { const affected = detail.containers?.cna?.affected?.slice(0, 3) ?? []; const aliases = detail.aliases ?? []; const relatedIds = extractRelatedIds(detail); - const priority = severity === "CRITICAL" ? "critical" : severity === "HIGH" ? "high" : severity === "MEDIUM" ? "medium" : "low"; - const status = priority === "critical" || priority === "high" ? "investigating" : "new"; + const referenceSummary = summarizeReferences(detail); + const projectContext = buildProjectContext(relatedProjects); + const signals = buildTriageSignals({ severity, severityScore, epss, referenceSummary, triage, projectContext, affected }); + const priority = derivePriority(severity, epss, referenceSummary, projectContext.projectCount); + const status = deriveStatus(priority, triage?.status); + const confidence = deriveConfidence(severityScore, epss, referenceSummary); return { summary: `${id} is a ${severity.toLowerCase()} severity vulnerability${affected.length ? ` affecting ${affected.map((item) => item.product || item.vendor).filter(Boolean).join(", ")}` : ""}. ${truncateSentence(description)}`, triage: { priority, status, + confidence, + ownerRecommendation: triage?.owner ? `Keep ${triage.owner} as the current owner unless product ownership has changed.` : projectContext.projectCount > 0 ? "Assign the owning engineering or service team tied to the impacted project." : "Assign a security or service owner before remediation work begins.", rationale: - priority === "critical" || priority === "high" - ? "High severity and affected product exposure suggest immediate analyst review." - : "The record is lower severity or missing severity context, so review is still useful but less urgent.", + buildTriageRationale({ priority, epss, referenceSummary, projectContext, triage }), nextSteps: [ "Confirm whether the affected product or version exists in your environment.", - "Review upstream references for patches, advisories, or mitigation guidance.", - "Track ownership and remediation notes in triage before closing the issue.", + referenceSummary.patchCount > 0 ? "Review the available patch or advisory references and determine the rollout path." : "Review upstream references for patches, advisories, or mitigation guidance.", + projectContext.projectCount > 0 ? "Coordinate remediation with the linked project owners and track the rollout decision." : "Track ownership and remediation notes in triage before closing the issue.", ], + signals, }, remediation: [ "Identify exposed versions and compare them against vendor-fixed releases.", - "Apply patches or compensating controls where an immediate upgrade is not possible.", + referenceSummary.patchCount > 0 ? "Apply the vendor patch path first, then fall back to compensating controls where rollout timing is constrained." : "Apply patches or compensating controls where an immediate upgrade is not possible.", "Validate remediation with version checks, changelog confirmation, or environment-specific testing.", ], cluster: { @@ -216,9 +233,193 @@ export function buildHeuristicCveInsight(detail: CVEDetail): AICveInsight { ? "This issue appears alongside linked advisories or aliases and should be reviewed as part of a broader context cluster." : "This issue currently stands alone in the available alias and linked-vulnerability context.", }, + projectContext, + }; +} + +function normalizeCveInsightInput(input: CVEDetail | CveInsightInput): CveInsightInput { + if ("detail" in input) { + return input; + } + + return { + detail: input, + epss: null, + triage: null, + relatedProjects: [], + }; +} + +function summarizeReferences(detail: CVEDetail): { + totalCount: number; + exploitCount: number; + patchCount: number; +} { + const urls = [ + ...(detail.references ?? []), + ...((detail.containers?.cna?.references ?? []).flatMap((reference) => (typeof reference.url === "string" ? [reference.url] : []))), + ]; + const tags = (detail.containers?.cna?.references ?? []).flatMap((reference) => + Array.isArray(reference.tags) ? reference.tags.filter((tag): tag is string => typeof tag === "string") : [] + ); + const combined = [...urls, ...tags].map((value) => value.toLowerCase()); + + return { + totalCount: urls.length, + exploitCount: combined.filter((value) => /exploit|proof|poc|weapon/i.test(value)).length, + patchCount: combined.filter((value) => /patch|fix|release|advisory|mitigation/i.test(value)).length, + }; +} + +function buildProjectContext(projects: Pick[]): AICveInsight["projectContext"] { + const projectNames = projects.map((project) => project.name).slice(0, 5); + + return { + projectCount: projects.length, + projectNames, + summary: + projects.length > 0 + ? `This CVE is already tracked in ${projects.length} project${projects.length === 1 ? "" : "s"}: ${projectNames.join(", ")}.` + : "This CVE is not currently linked to any tracked project.", }; } +function buildTriageSignals(input: { + severity: ReturnType; + severityScore?: number; + epss: EPSSData | null; + referenceSummary: { totalCount: number; exploitCount: number; patchCount: number }; + triage: AITriageContextSnapshot | null; + projectContext: AICveInsight["projectContext"]; + affected: Array<{ vendor?: string; product?: string }>; +}): AITriageSignal[] { + const signals: AITriageSignal[] = []; + + signals.push({ + label: "Severity", + value: input.severityScore ? `${input.severity} (${input.severityScore.toFixed(1)})` : input.severity, + level: input.severity === "CRITICAL" || input.severity === "HIGH" ? "high" : input.severity === "MEDIUM" ? "medium" : "low", + rationale: "Severity is derived from the best available CVSS score.", + }); + + if (input.epss) { + signals.push({ + label: "EPSS", + value: `${(input.epss.epss * 100).toFixed(2)}% (${(input.epss.percentile * 100).toFixed(1)} percentile)`, + level: input.epss.percentile >= 0.9 ? "high" : input.epss.percentile >= 0.5 ? "medium" : "low", + rationale: "EPSS indicates the relative likelihood of exploitation in the wild.", + }); + } + + if (input.referenceSummary.totalCount > 0) { + signals.push({ + label: "References", + value: `${input.referenceSummary.totalCount} refs / ${input.referenceSummary.patchCount} patch-like / ${input.referenceSummary.exploitCount} exploit-like`, + level: input.referenceSummary.exploitCount > 0 ? "high" : input.referenceSummary.patchCount > 0 ? "medium" : "low", + rationale: "Reference quality helps distinguish active exploitation discussion from routine disclosure metadata.", + }); + } + + if (input.projectContext.projectCount > 0) { + signals.push({ + label: "Project impact", + value: `${input.projectContext.projectCount} linked project${input.projectContext.projectCount === 1 ? "" : "s"}`, + level: input.projectContext.projectCount >= 2 ? "high" : "medium", + rationale: "Existing project linkage suggests known internal relevance and active tracking.", + }); + } + + if (input.triage) { + signals.push({ + label: "Analyst workflow", + value: `${input.triage.status}${input.triage.owner ? ` • ${input.triage.owner}` : ""}`, + level: input.triage.status === "investigating" ? "high" : input.triage.status === "new" ? "medium" : "low", + rationale: "Current analyst workflow state should shape the next recommended action instead of overwriting it blindly.", + }); + } + + if (input.affected.length > 0) { + signals.push({ + label: "Affected products", + value: input.affected + .map((item) => [item.vendor, item.product].filter(Boolean).join("/")) + .filter(Boolean) + .slice(0, 3) + .join(", "), + level: "medium", + rationale: "Affected product metadata indicates where to validate exposure first.", + }); + } + + return signals.slice(0, 5); +} + +function derivePriority( + severity: ReturnType, + epss: EPSSData | null, + referenceSummary: { totalCount: number; exploitCount: number; patchCount: number }, + projectCount: number +): AICveInsight["triage"]["priority"] { + if (severity === "CRITICAL") return "critical"; + if (severity === "HIGH" || (epss?.percentile ?? 0) >= 0.9 || referenceSummary.exploitCount > 0) return "high"; + if (severity === "MEDIUM" || (epss?.percentile ?? 0) >= 0.5 || projectCount > 0 || referenceSummary.patchCount > 0) return "medium"; + return "low"; +} + +function deriveStatus( + priority: AICveInsight["triage"]["priority"], + existingStatus?: AITriageContextSnapshot["status"] +): AICveInsight["triage"]["status"] { + if (existingStatus && existingStatus !== "new") { + return existingStatus; + } + + return priority === "critical" || priority === "high" ? "investigating" : existingStatus ?? "new"; +} + +function deriveConfidence( + severityScore: number | undefined, + epss: EPSSData | null, + referenceSummary: { totalCount: number; exploitCount: number; patchCount: number } +): AICveInsight["triage"]["confidence"] { + const evidenceCount = Number(Boolean(severityScore)) + Number(Boolean(epss)) + Number(referenceSummary.totalCount > 0); + if (evidenceCount >= 3) return "high"; + if (evidenceCount === 2) return "medium"; + return "low"; +} + +function buildTriageRationale(input: { + priority: AICveInsight["triage"]["priority"]; + epss: EPSSData | null; + referenceSummary: { totalCount: number; exploitCount: number; patchCount: number }; + projectContext: AICveInsight["projectContext"]; + triage: AITriageContextSnapshot | null; +}): string { + const reasons: string[] = []; + + reasons.push(`The recommendation is ${input.priority} priority based on the available severity and exploitation signals.`); + + if (input.epss) { + reasons.push(`EPSS is ${(input.epss.epss * 100).toFixed(2)}% at the ${(input.epss.percentile * 100).toFixed(1)} percentile.`); + } + + if (input.referenceSummary.exploitCount > 0) { + reasons.push("Reference metadata includes exploit-like indicators, which raises urgency."); + } else if (input.referenceSummary.patchCount > 0) { + reasons.push("Patch or advisory references are already available, which improves remediation readiness."); + } + + if (input.projectContext.projectCount > 0) { + reasons.push(input.projectContext.summary); + } + + if (input.triage?.owner) { + reasons.push(`The current workflow already names ${input.triage.owner} as owner, so the recommendation preserves that context.`); + } + + return reasons.join(" "); +} + export function interpretSearchPromptHeuristically(prompt: string): AISearchInterpretation { return buildSearchInterpretationFromPlan(prompt, runSearchPlanning(prompt)); } @@ -611,6 +812,7 @@ function sanitizeInsight(value: unknown): AICveInsight { triage: sanitizeTriage(record.triage, fallback.triage), remediation: Array.isArray(record.remediation) ? record.remediation.filter((item): item is string => typeof item === "string").slice(0, 6) : fallback.remediation, cluster: sanitizeCluster(record.cluster, fallback.cluster), + projectContext: sanitizeProjectContext(record.projectContext, fallback.projectContext), }; } @@ -692,10 +894,37 @@ function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICve record.status === "new" || record.status === "investigating" || record.status === "mitigated" || record.status === "accepted" || record.status === "closed" ? record.status : fallback.status, + confidence: record.confidence === "high" || record.confidence === "medium" || record.confidence === "low" ? record.confidence : fallback.confidence, + ownerRecommendation: typeof record.ownerRecommendation === "string" ? record.ownerRecommendation : fallback.ownerRecommendation, rationale: typeof record.rationale === "string" ? record.rationale : fallback.rationale, nextSteps: Array.isArray(record.nextSteps) ? record.nextSteps.filter((item): item is string => typeof item === "string").slice(0, 6) : fallback.nextSteps, + signals: Array.isArray(record.signals) + ? record.signals + .filter((item): item is Record => Boolean(item) && typeof item === "object") + .flatMap((item) => { + const label = item.label; + const value = item.value; + const level = item.level; + const rationale = item.rationale; + return typeof label === "string" && typeof value === "string" && (level === "high" || level === "medium" || level === "low") && typeof rationale === "string" + ? [{ label, value, level, rationale }] + : []; + }) + : fallback.signals, + }; +} + +function sanitizeProjectContext(value: unknown, fallback: AICveInsight["projectContext"]): AICveInsight["projectContext"] { + if (!value || typeof value !== "object") return fallback; + const record = value as Record; + return { + projectCount: typeof record.projectCount === "number" && Number.isFinite(record.projectCount) ? record.projectCount : fallback.projectCount, + projectNames: Array.isArray(record.projectNames) + ? record.projectNames.filter((item): item is string => typeof item === "string").slice(0, 8) + : fallback.projectNames, + summary: typeof record.summary === "string" ? record.summary : fallback.summary, }; } diff --git a/src/lib/server-api.ts b/src/lib/server-api.ts index cb54350..4f3b965 100644 --- a/src/lib/server-api.ts +++ b/src/lib/server-api.ts @@ -1,4 +1,4 @@ -import { CVEDetail, CVESummary, HomeDashboardData } from "./types"; +import { CVEDetail, CVESummary, EPSSData, HomeDashboardData } from "./types"; import { SearchState } from "./search"; import { applySearchResultPreferences, @@ -9,7 +9,7 @@ import { matchesSearchState, wasPublishedWithinDays, } from "./search"; -import { parseCVEDetail, parseCVESummaryList } from "./validation"; +import { parseCVEDetail, parseCVESummaryList, parseEPSSResponse } from "./validation"; const API_BASE = "https://vulnerability.circl.lu/api"; type NextFetchOptions = RequestInit & { next?: { revalidate: number } }; @@ -66,6 +66,15 @@ export async function getCVEByIdServer(id: string): Promise { return parseCVEDetail(data); } +export async function getEPSSServer(cveId: string): Promise { + try { + const data = await fetchUpstream(`/epss/${encodeURIComponent(cveId)}`); + return parseEPSSResponse(data); + } catch { + return null; + } +} + export async function searchByVendorProductServer( vendor: string, product: string, diff --git a/src/lib/types.ts b/src/lib/types.ts index 27ea116..38467e5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -182,6 +182,14 @@ export interface ProjectRecord { items: ProjectItem[]; } +export interface AITriageContextSnapshot { + status: "new" | "investigating" | "mitigated" | "accepted" | "closed"; + owner: string; + notes: string; + tags: string[]; + updatedAt: string; +} + export interface AIContextCluster { canonicalId: string; sourceIds: string[]; @@ -189,11 +197,27 @@ export interface AIContextCluster { summary: string; } +export interface AITriageSignal { + label: string; + value: string; + level: "high" | "medium" | "low"; + rationale: string; +} + export interface AITriageRecommendation { priority: "critical" | "high" | "medium" | "low"; status: "new" | "investigating" | "mitigated" | "accepted" | "closed"; + confidence: "high" | "medium" | "low"; + ownerRecommendation: string; rationale: string; nextSteps: string[]; + signals: AITriageSignal[]; +} + +export interface AIProjectContext { + projectCount: number; + projectNames: string[]; + summary: string; } export interface AICveInsight { @@ -201,6 +225,7 @@ export interface AICveInsight { triage: AITriageRecommendation; remediation: string[]; cluster: AIContextCluster; + projectContext: AIProjectContext; } export type AISearchFilterField = "query" | "vendor" | "product" | "cwe" | "since" | "minSeverity" | "sort"; diff --git a/tests/ai.test.ts b/tests/ai.test.ts index 0df92b6..79d6a78 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -39,8 +39,56 @@ test("buildHeuristicCveInsight produces triage and remediation guidance", () => }); assert.equal(result.triage.priority, "critical"); + assert.equal(result.triage.confidence, "low"); assert.equal(result.cluster.canonicalId, "CVE-2026-1111"); assert.equal(result.remediation.length > 0, true); + assert.equal(result.triage.signals.some((signal) => signal.label === "Severity"), true); + assert.equal(result.projectContext.projectCount, 0); +}); + +test("buildHeuristicCveInsight incorporates epss triage workflow and project context", () => { + const result = buildHeuristicCveInsight({ + detail: { + id: "CVE-2026-2222", + cvss3: 7.8, + summary: "High-severity issue in a web edge component", + references: ["https://vendor.example/advisory", "https://research.example/exploit-poc"], + containers: { + cna: { + affected: [{ vendor: "acme", product: "edge-proxy" }], + references: [{ url: "https://vendor.example/patch", tags: ["patch", "vendor-advisory"] }], + }, + }, + }, + epss: { + cve: "CVE-2026-2222", + epss: 0.83, + percentile: 0.96, + }, + triage: { + status: "investigating", + owner: "edge-platform", + notes: "Internet-facing service", + tags: ["internet-facing"], + updatedAt: "2026-03-06T10:00:00.000Z", + }, + relatedProjects: [ + { + name: "Edge Platform", + updatedAt: "2026-03-06T10:00:00.000Z", + items: [{ cveId: "CVE-2026-2222", addedAt: "2026-03-06T09:00:00.000Z" }], + }, + ], + }); + + assert.equal(result.triage.priority, "high"); + assert.equal(result.triage.status, "investigating"); + assert.equal(result.triage.confidence, "high"); + assert.match(result.triage.ownerRecommendation, /edge-platform/i); + assert.equal(result.triage.signals.some((signal) => signal.label === "EPSS"), true); + assert.equal(result.triage.signals.some((signal) => signal.label === "Project impact"), true); + assert.equal(result.projectContext.projectCount, 1); + assert.deepEqual(result.projectContext.projectNames, ["Edge Platform"]); }); test("buildHeuristicDigest summarizes watchlist, alerts, and projects", () => { From 9571d6b7818dd33aea1476a9e38c5928c11f2ee7 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 12:41:02 +0100 Subject: [PATCH 03/30] feat(ai): persist and review recent ai runs --- src/app/api/ai/runs/route.ts | 16 +++ src/app/settings/page.tsx | 7 +- src/components/AISettingsPageClient.tsx | 62 ++++++++++- src/lib/ai-runs-store.ts | 83 +++++++++++++++ src/lib/ai-service.ts | 130 ++++++++++++++++++++++-- src/lib/types.ts | 17 ++++ tests/ai-runs-store.test.ts | 58 +++++++++++ 7 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 src/app/api/ai/runs/route.ts create mode 100644 src/lib/ai-runs-store.ts create mode 100644 tests/ai-runs-store.test.ts diff --git a/src/app/api/ai/runs/route.ts b/src/app/api/ai/runs/route.ts new file mode 100644 index 0000000..36b9641 --- /dev/null +++ b/src/app/api/ai/runs/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRecentAIRuns } from "@/lib/ai-service"; + +export async function GET(request: NextRequest) { + try { + const rawLimit = request.nextUrl.searchParams.get("limit"); + const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 25; + const runs = await getRecentAIRuns(limit); + return NextResponse.json(runs); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to load AI runs" }, + { status: 500 } + ); + } +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index c840e93..c1fd854 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,8 +1,9 @@ import AISettingsPageClient from "@/components/AISettingsPageClient"; -import { getServerAIConfigurationSummary } from "@/lib/ai-service"; +import { getRecentAIRuns, getServerAIConfigurationSummary } from "@/lib/ai-service"; -export default function SettingsPage() { +export default async function SettingsPage() { const summary = getServerAIConfigurationSummary(); + const recentRuns = await getRecentAIRuns(12); - return ; + return ; } diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 5a29282..a01d221 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; import { ServerAIConfigurationSummary } from "@/lib/ai-service"; +import { AIRunRecord } from "@/lib/types"; -export default function AISettingsPageClient({ summary }: { summary: ServerAIConfigurationSummary }) { +export default function AISettingsPageClient({ summary, recentRuns }: { summary: ServerAIConfigurationSummary; recentRuns: AIRunRecord[] }) { return (
    @@ -47,6 +48,65 @@ export default function AISettingsPageClient({ summary }: { summary: ServerAICon : "No model provider credentials detected on the server."}
    + +
    +
    +
    +

    Recent AI Runs

    +

    Read-only history of recent prompts, outcomes, tool traces, and failures.

    +
    +
    + + {recentRuns.length > 0 ? ( +
    + {recentRuns.map((run) => ( +
    +
    + {run.feature} + {run.status} + {run.provider}{run.model ? ` • ${run.model}` : ""} + {new Date(run.createdAt).toLocaleString("en-US")} + {run.durationMs}ms +
    + +
    +
    +

    Prompt

    +
    {run.prompt}
    +
    + +
    +

    Output

    +
    {run.output}
    +
    + + {run.toolCalls.length > 0 ? ( +
    +

    Tool Calls

    +
      + {run.toolCalls.map((call) => ( +
    • + {call.tool} + — {call.summary} +
    • + ))} +
    +
    + ) : null} + + {run.error ? ( +
    + {run.error} +
    + ) : null} +
    +
    + ))} +
    + ) : ( +

    No AI runs have been recorded yet.

    + )} +
    ); diff --git a/src/lib/ai-runs-store.ts b/src/lib/ai-runs-store.ts new file mode 100644 index 0000000..eaaa59c --- /dev/null +++ b/src/lib/ai-runs-store.ts @@ -0,0 +1,83 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { AIRunRecord } from "./types"; + +const DATA_DIR = path.join(process.cwd(), "data"); +const MAX_STORED_AI_RUNS = 200; + +export async function listRecentAIRuns(limit = 25): Promise { + const runs = await readAIRuns(); + return runs.slice(0, normalizeLimit(limit)); +} + +export async function appendAIRun(record: AIRunRecord): Promise { + const runs = await readAIRuns(); + const next = [normalizeAIRun(record), ...runs].slice(0, MAX_STORED_AI_RUNS); + await writeAIRuns(next); +} + +async function readAIRuns(): Promise { + try { + const raw = await fs.readFile(getAIRunsFile(), "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isAIRunRecord).map(normalizeAIRun) : []; + } catch { + return []; + } +} + +async function writeAIRuns(runs: AIRunRecord[]): Promise { + const file = getAIRunsFile(); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(runs, null, 2)); +} + +function getAIRunsFile(): string { + return process.env.AI_RUNS_FILE?.trim() || path.join(DATA_DIR, "ai-runs.json"); +} + +function normalizeAIRun(record: AIRunRecord): AIRunRecord { + return { + id: record.id, + feature: record.feature, + provider: record.provider, + model: record.model, + mode: record.mode, + status: record.status, + prompt: record.prompt, + output: record.output, + toolCalls: Array.isArray(record.toolCalls) + ? record.toolCalls + .filter((call): call is AIRunRecord["toolCalls"][number] => Boolean(call) && typeof call === "object") + .map((call) => ({ tool: call.tool, summary: call.summary })) + : [], + error: record.error, + durationMs: Number.isFinite(record.durationMs) ? record.durationMs : 0, + createdAt: record.createdAt, + }; +} + +function isAIRunRecord(value: unknown): value is AIRunRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + + return ( + typeof record.id === "string" && + typeof record.feature === "string" && + typeof record.provider === "string" && + typeof record.model === "string" && + typeof record.mode === "string" && + typeof record.status === "string" && + typeof record.prompt === "string" && + typeof record.output === "string" && + Array.isArray(record.toolCalls) && + typeof record.error === "string" && + typeof record.durationMs === "number" && + typeof record.createdAt === "string" + ); +} + +function normalizeLimit(limit: number): number { + if (!Number.isFinite(limit)) return 25; + return Math.min(Math.max(Math.floor(limit), 1), 100); +} diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index a6f0466..f789c83 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -2,6 +2,7 @@ import { AICveInsight, AIDigest, AIFeature, + AIRunRecord, AITriageContextSnapshot, AITriageSignal, AIProvider, @@ -15,6 +16,7 @@ import { SearchSeverityFilter, SearchSortOption, } from "./types"; +import { appendAIRun, listRecentAIRuns } from "./ai-runs-store"; import { SearchState, normalizeSearchState } from "./search"; import { extractCVEId, extractDescription, getSeverityFromScore } from "./utils"; @@ -58,6 +60,7 @@ interface StructuredTask { prompt: string; fallback: () => T; sanitize: (value: unknown) => T; + toolCalls?: AISearchToolTrace[]; } interface SearchToolContext { @@ -122,6 +125,10 @@ export function getServerAIConfigurationSummary(): ServerAIConfigurationSummary }; } +export async function getRecentAIRuns(limit = 25): Promise { + return listRecentAIRuns(limit); +} + export async function generateCveInsight(input: CveInsightInput): Promise { return executeStructuredTask({ feature: "cve_insight", @@ -141,8 +148,19 @@ export async function generateSearchInterpretation(prompt: string): Promise({ feature, prompt, fallback, sanitize }: StructuredTask): Promise { +async function executeStructuredTask({ feature, prompt, fallback, sanitize, toolCalls = [] }: StructuredTask): Promise { const runtime = resolveAIRuntime(); + const startedAt = Date.now(); if (runtime.mode === "heuristic") { - return fallback(); + const result = fallback(); + await persistAIRun({ + feature, + runtime, + status: "fallback", + prompt, + output: safeSerialize(result), + toolCalls, + durationMs: Date.now() - startedAt, + error: "", + }); + return result; } try { const response = await callModel(prompt, runtime, feature); - return sanitize(JSON.parse(response)); - } catch { - return fallback(); + const result = sanitize(JSON.parse(response)); + await persistAIRun({ + feature, + runtime, + status: "success", + prompt, + output: safeSerialize(result), + toolCalls, + durationMs: Date.now() - startedAt, + error: "", + }); + return result; + } catch (error) { + const result = fallback(); + await persistAIRun({ + feature, + runtime, + status: "fallback", + prompt, + output: safeSerialize(result), + toolCalls, + durationMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : "unknown_error", + }); + return result; } } @@ -479,7 +552,7 @@ function runSearchPlanning(prompt: string): SearchPlanningResult { lower: prompt.toLowerCase(), }; - const catalog = inspectAvailableFilters(context); + const catalog = inspectAvailableFilters(); const extracted = extractPromptSignals(context); const time = resolveRelativeTime(context); const clarification = detectClarificationNeed(context, extracted); @@ -511,7 +584,7 @@ function runSearchPlanning(prompt: string): SearchPlanningResult { }; } -function inspectAvailableFilters(_context: SearchToolContext): SearchFilterCatalog { +function inspectAvailableFilters(): SearchFilterCatalog { return { fields: ["query", "vendor", "product", "cwe", "since", "minSeverity", "sort"], minSeverity: ["ANY", "LOW", "MEDIUM", "HIGH", "CRITICAL"], @@ -675,6 +748,47 @@ async function callModel(prompt: string, settings: AIRuntimeSettings, feature: A return callOpenAI(prompt, settings, feature); } +async function persistAIRun(input: { + feature: AIFeature; + runtime: AIRuntimeSettings; + status: AIRunRecord["status"]; + prompt: string; + output: string; + toolCalls: AISearchToolTrace[]; + durationMs: number; + error: string; +}): Promise { + try { + await appendAIRun({ + id: crypto.randomUUID(), + feature: input.feature, + provider: input.runtime.provider, + model: input.runtime.model, + mode: input.runtime.mode, + status: input.status, + prompt: truncateValue(input.prompt, 12000), + output: truncateValue(input.output, 12000), + toolCalls: input.toolCalls, + error: truncateValue(input.error, 2000), + durationMs: Math.max(0, Math.round(input.durationMs)), + createdAt: new Date().toISOString(), + }); + } catch { + } +} + +function safeSerialize(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return "{\"error\":\"serialization_failed\"}"; + } +} + +function truncateValue(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength).trimEnd()}...`; +} + async function callOpenAI(prompt: string, settings: AIRuntimeSettings, feature: AIFeature): Promise { const res = await fetch(OPENAI_API_URL, { method: "POST", diff --git a/src/lib/types.ts b/src/lib/types.ts index 38467e5..37b8bff 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -268,6 +268,23 @@ export interface AIDigest { sections: AIDigestSection[]; } +export type AIRunStatus = "success" | "fallback" | "error"; + +export interface AIRunRecord { + id: string; + feature: AIFeature; + provider: AIProvider; + model: string; + mode: "heuristic" | "configured"; + status: AIRunStatus; + prompt: string; + output: string; + toolCalls: AISearchToolTrace[]; + error: string; + durationMs: number; + createdAt: string; +} + export type AIProvider = "heuristic" | "openai" | "anthropic"; export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest"; diff --git a/tests/ai-runs-store.test.ts b/tests/ai-runs-store.test.ts new file mode 100644 index 0000000..d801f31 --- /dev/null +++ b/tests/ai-runs-store.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { appendAIRun, listRecentAIRuns } from "../src/lib/ai-runs-store"; +import { AIRunRecord } from "../src/lib/types"; + +test("appendAIRun stores newest runs first and listRecentAIRuns enforces the limit", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cvesearch-ai-runs-")); + const previous = process.env.AI_RUNS_FILE; + process.env.AI_RUNS_FILE = path.join(tempDir, "ai-runs.json"); + + const first: AIRunRecord = { + id: "run-1", + feature: "search_assistant", + provider: "heuristic", + model: "", + mode: "heuristic", + status: "fallback", + prompt: "first prompt", + output: "first output", + toolCalls: [{ tool: "inspect_available_filters", summary: "fields loaded" }], + error: "", + durationMs: 10, + createdAt: "2026-03-06T11:00:00.000Z", + }; + + const second: AIRunRecord = { + ...first, + id: "run-2", + prompt: "second prompt", + output: "second output", + createdAt: "2026-03-06T11:01:00.000Z", + }; + + try { + await appendAIRun(first); + await appendAIRun(second); + + const allRuns = await listRecentAIRuns(10); + const limitedRuns = await listRecentAIRuns(1); + + assert.equal(allRuns.length, 2); + assert.equal(allRuns[0].id, "run-2"); + assert.equal(allRuns[1].id, "run-1"); + assert.equal(allRuns[0].toolCalls[0]?.tool, "inspect_available_filters"); + assert.equal(limitedRuns.length, 1); + assert.equal(limitedRuns[0].id, "run-2"); + } finally { + if (previous === undefined) { + delete process.env.AI_RUNS_FILE; + } else { + process.env.AI_RUNS_FILE = previous; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); From 852b94224023e99d540fe440c56a572f83213890 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 12:45:02 +0100 Subject: [PATCH 04/30] feat(ai): add per-feature runtime configuration --- src/components/AISettingsPageClient.tsx | 28 ++++++++++- src/lib/ai-service.ts | 62 +++++++++++++++++++++---- tests/ai.test.ts | 50 ++++++++++++++++++++ 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index a01d221..1117204 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -32,7 +32,7 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary:
    - Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. No provider API key is persisted in browser storage. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage.
    @@ -49,6 +49,32 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary:
    +
    +
    +

    Per-Feature Configuration

    +

    Each AI flow can inherit the global server configuration or override it with feature-specific provider and model settings.

    +
    + +
    + {summary.featureConfigurations.map((featureConfig) => ( +
    +
    +

    {featureConfig.feature}

    + {featureConfig.mode === "configured" ? "Configured" : "Heuristic"} +
    +
    +

    + Provider: {featureConfig.provider} +

    +

    + Model: {featureConfig.model || "Not required in heuristic mode"} +

    +
    +
    + ))} +
    +
    +
    diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index f789c83..8127ba6 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -26,6 +26,12 @@ const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; const SEARCH_DEFAULT_SORT: SearchSortOption = "published_desc"; const SEARCH_DEFAULT_MIN_SEVERITY: SearchSeverityFilter = "ANY"; +const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest"]; +const AI_FEATURE_ENV_SEGMENTS: Record = { + search_assistant: "SEARCH_ASSISTANT", + cve_insight: "CVE_INSIGHT", + daily_digest: "DAILY_DIGEST", +}; export interface DigestInput { watchlist: Array<{ id: string; summary?: string; severity?: string }>; @@ -46,6 +52,13 @@ export interface ServerAIConfigurationSummary { mode: "heuristic" | "configured"; configured: boolean; availableProviders: AIProvider[]; + featureConfigurations: Array<{ + feature: AIFeature; + provider: AIProvider; + model: string; + mode: "heuristic" | "configured"; + configured: boolean; + }>; } interface AIRuntimeSettings { @@ -122,6 +135,17 @@ export function getServerAIConfigurationSummary(): ServerAIConfigurationSummary mode: runtime.mode, configured: runtime.mode === "configured", availableProviders, + featureConfigurations: AI_FEATURES.map((feature) => { + const featureRuntime = resolveAIRuntime(feature); + + return { + feature, + provider: featureRuntime.provider, + model: featureRuntime.model, + mode: featureRuntime.mode, + configured: featureRuntime.mode === "configured", + }; + }), }; } @@ -147,7 +171,7 @@ export async function generateCveInsight(input: CveInsightInput): Promise { const plan = runSearchPlanning(prompt); const heuristic = buildSearchInterpretationFromPlan(prompt, plan); - const runtime = resolveAIRuntime(); + const runtime = resolveAIRuntime("search_assistant"); const startedAt = Date.now(); if (runtime.mode === "heuristic") { @@ -499,7 +523,7 @@ export function buildHeuristicDigest(input: DigestInput): AIDigest { } async function executeStructuredTask({ feature, prompt, fallback, sanitize, toolCalls = [] }: StructuredTask): Promise { - const runtime = resolveAIRuntime(); + const runtime = resolveAIRuntime(feature); const startedAt = Date.now(); if (runtime.mode === "heuristic") { const result = fallback(); @@ -859,15 +883,27 @@ async function callAnthropic(prompt: string, settings: AIRuntimeSettings, featur return content; } -function resolveAIRuntime(): AIRuntimeSettings { - const requestedProvider = normalizeProvider(process.env.AI_PROVIDER); +function resolveAIRuntime(feature?: AIFeature): AIRuntimeSettings { + const requestedProvider = normalizeProvider(readFeatureEnv(feature, "PROVIDER") ?? process.env.AI_PROVIDER); + const requestedModel = readFeatureEnv(feature, "MODEL") ?? ""; const openAIKey = process.env.OPENAI_API_KEY?.trim() ?? ""; const anthropicKey = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; + const openAIModel = (requestedModel || process.env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL).trim(); + const anthropicModel = (requestedModel || process.env.ANTHROPIC_MODEL || DEFAULT_ANTHROPIC_MODEL).trim(); + + if (requestedProvider === "heuristic") { + return { + provider: "heuristic", + model: "", + apiKey: "", + mode: "heuristic", + }; + } if (requestedProvider === "openai" && openAIKey) { return { provider: "openai", - model: (process.env.OPENAI_MODEL ?? DEFAULT_OPENAI_MODEL).trim(), + model: openAIModel, apiKey: openAIKey, mode: "configured", }; @@ -876,7 +912,7 @@ function resolveAIRuntime(): AIRuntimeSettings { if (requestedProvider === "anthropic" && anthropicKey) { return { provider: "anthropic", - model: (process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC_MODEL).trim(), + model: anthropicModel, apiKey: anthropicKey, mode: "configured", }; @@ -885,7 +921,7 @@ function resolveAIRuntime(): AIRuntimeSettings { if (openAIKey) { return { provider: "openai", - model: (process.env.OPENAI_MODEL ?? DEFAULT_OPENAI_MODEL).trim(), + model: openAIModel, apiKey: openAIKey, mode: "configured", }; @@ -894,7 +930,7 @@ function resolveAIRuntime(): AIRuntimeSettings { if (anthropicKey) { return { provider: "anthropic", - model: (process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC_MODEL).trim(), + model: anthropicModel, apiKey: anthropicKey, mode: "configured", }; @@ -908,6 +944,16 @@ function resolveAIRuntime(): AIRuntimeSettings { }; } +function readFeatureEnv(feature: AIFeature | undefined, suffix: "PROVIDER" | "MODEL"): string | undefined { + if (!feature) { + return undefined; + } + + const envKey = `AI_${AI_FEATURE_ENV_SEGMENTS[feature]}_${suffix}`; + const value = process.env[envKey]?.trim(); + return value || undefined; +} + function normalizeProvider(value: string | undefined): AIProvider | undefined { if (value === "openai" || value === "anthropic" || value === "heuristic") { return value; diff --git a/tests/ai.test.ts b/tests/ai.test.ts index 79d6a78..093244a 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildHeuristicCveInsight, buildHeuristicDigest, + getServerAIConfigurationSummary, interpretSearchPromptHeuristically, } from "../src/lib/ai"; @@ -101,3 +102,52 @@ test("buildHeuristicDigest summarizes watchlist, alerts, and projects", () => { assert.match(result.headline, /Critical OpenSSL|Tracking/); assert.equal(result.sections.length, 3); }); + +test("getServerAIConfigurationSummary applies per-feature provider and model overrides", () => { + const previous = { + AI_PROVIDER: process.env.AI_PROVIDER, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_MODEL: process.env.OPENAI_MODEL, + AI_SEARCH_ASSISTANT_PROVIDER: process.env.AI_SEARCH_ASSISTANT_PROVIDER, + AI_SEARCH_ASSISTANT_MODEL: process.env.AI_SEARCH_ASSISTANT_MODEL, + AI_CVE_INSIGHT_PROVIDER: process.env.AI_CVE_INSIGHT_PROVIDER, + AI_CVE_INSIGHT_MODEL: process.env.AI_CVE_INSIGHT_MODEL, + AI_DAILY_DIGEST_PROVIDER: process.env.AI_DAILY_DIGEST_PROVIDER, + AI_DAILY_DIGEST_MODEL: process.env.AI_DAILY_DIGEST_MODEL, + }; + + process.env.AI_PROVIDER = "openai"; + process.env.OPENAI_API_KEY = "test-openai-key"; + process.env.OPENAI_MODEL = "gpt-global"; + process.env.AI_SEARCH_ASSISTANT_PROVIDER = "heuristic"; + process.env.AI_SEARCH_ASSISTANT_MODEL = "ignored-search-model"; + process.env.AI_CVE_INSIGHT_PROVIDER = "openai"; + process.env.AI_CVE_INSIGHT_MODEL = "gpt-cve"; + process.env.AI_DAILY_DIGEST_PROVIDER = "openai"; + process.env.AI_DAILY_DIGEST_MODEL = "gpt-digest"; + + try { + const summary = getServerAIConfigurationSummary(); + const search = summary.featureConfigurations.find((item) => item.feature === "search_assistant"); + const cveInsight = summary.featureConfigurations.find((item) => item.feature === "cve_insight"); + const digest = summary.featureConfigurations.find((item) => item.feature === "daily_digest"); + + assert.equal(summary.provider, "openai"); + assert.equal(summary.model, "gpt-global"); + assert.equal(search?.provider, "heuristic"); + assert.equal(search?.mode, "heuristic"); + assert.equal(search?.model, ""); + assert.equal(cveInsight?.provider, "openai"); + assert.equal(cveInsight?.model, "gpt-cve"); + assert.equal(digest?.provider, "openai"); + assert.equal(digest?.model, "gpt-digest"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}); From 0cf81f386900d75c59588d4e04a1b4d7db460d24 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 12:54:43 +0100 Subject: [PATCH 05/30] feat(search): add KEV-aware risk prioritization --- src/components/CVECard.tsx | 22 ++++++++++ src/components/DashboardPanel.tsx | 9 ++-- src/components/Filters.tsx | 1 + src/components/HomePageClient.tsx | 2 + src/lib/ai-service.ts | 7 ++-- src/lib/search.ts | 47 +++++++++++++++++++-- src/lib/server-api.ts | 70 +++++++++++++++++++++++++------ src/lib/types.ts | 21 +++++++++- src/lib/validation.ts | 48 ++++++++++++++++++++- tests/search.test.ts | 34 +++++++++++++++ tests/validation.test.ts | 28 +++++++++++++ 11 files changed, 263 insertions(+), 26 deletions(-) diff --git a/src/components/CVECard.tsx b/src/components/CVECard.tsx index 3a7fb5b..638c01b 100644 --- a/src/components/CVECard.tsx +++ b/src/components/CVECard.tsx @@ -11,6 +11,7 @@ import { extractSourceId, truncate, } from "@/lib/utils"; +import { getExploitReferenceCount } from "@/lib/search"; import SeverityBadge from "./SeverityBadge"; import BookmarkButton from "./BookmarkButton"; import CopyLinkButton from "./CopyLinkButton"; @@ -33,6 +34,7 @@ export default function CVECard({ cve }: CVECardProps) { const severity = getSeverityFromScore(score); const href = `/cve/${encodeURIComponent(cveId)}`; const affectedProducts = (cve.vulnerable_product ?? []).slice(0, 3); + const exploitReferenceCount = getExploitReferenceCount(cve); useEffect(() => { const sync = () => setTriageStatus(readTriageRecord(cveId).status); @@ -57,6 +59,26 @@ export default function CVECard({ cve }: CVECardProps) { {score !== undefined && score !== null && ( )} + {cve.kev && ( + + KEV + + )} + {cve.kev?.knownRansomwareCampaignUse === "Known" && ( + + Ransomware + + )} + {typeof cve.epss === "number" && cve.epss >= 0.2 && ( + + EPSS {(cve.epss * 100).toFixed(0)}% + + )} + {exploitReferenceCount > 0 && ( + + {exploitReferenceCount} exploit ref{exploitReferenceCount === 1 ? "" : "s"} + + )} {cve.state && cve.state !== "PUBLISHED" && ( {cve.state} diff --git a/src/components/DashboardPanel.tsx b/src/components/DashboardPanel.tsx index 15ed6b0..c6f67b5 100644 --- a/src/components/DashboardPanel.tsx +++ b/src/components/DashboardPanel.tsx @@ -11,10 +11,11 @@ interface DashboardPanelProps { export default function DashboardPanel({ dashboard }: DashboardPanelProps) { return (
    -
    +
    +
    @@ -37,9 +38,9 @@ export default function DashboardPanel({ dashboard }: DashboardPanelProps) { cves={dashboard.latestCritical} /> setSort(event.target.value as SearchSortOption)} className="w-full rounded-lg border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-sm text-white outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/30" > + diff --git a/src/components/HomePageClient.tsx b/src/components/HomePageClient.tsx index ea81186..1f107b8 100644 --- a/src/components/HomePageClient.tsx +++ b/src/components/HomePageClient.tsx @@ -224,6 +224,8 @@ function FilterChip({ label }: { label: string }) { function formatSortLabel(sort: SearchState["sort"]): string { switch (sort) { + case "risk_desc": + return "Highest risk"; case "published_asc": return "Oldest first"; case "cvss_desc": diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index 8127ba6..e83d9e5 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -194,7 +194,7 @@ export async function generateSearchInterpretation(prompt: string): Promise compareCVEs(left, right, state.sort)); } +export function getExploitReferenceCount(cve: CVESummary): number { + return (cve.references ?? []).filter((reference) => EXPLOIT_REFERENCE_PATTERN.test(reference)).length; +} + +export function hasExploitSignals(cve: CVESummary): boolean { + return getExploitReferenceCount(cve) > 0; +} + +export function getRiskScore(cve: CVESummary): number { + const severityScore = scoreForSort(cve); + const epssScore = Math.round((cve.epss ?? 0) * 100); + const exploitScore = Math.min(getExploitReferenceCount(cve), 3) * 15; + const kevScore = cve.kev ? 120 : 0; + const ransomwareScore = cve.kev?.knownRansomwareCampaignUse === "Known" ? 25 : 0; + const recencyScore = Math.min(daysSincePublished(cve), 30); + + return kevScore + ransomwareScore + epssScore + exploitScore + severityScore * 10 + (30 - recencyScore); +} + export function buildPresetHref(state: Partial): string { const params = buildSearchParams(state); return params.toString() ? `/?${params.toString()}` : "/"; @@ -201,6 +221,20 @@ function matchesCweFilter(cve: CVESummary, cwe: string): boolean { } function compareCVEs(left: CVESummary, right: CVESummary, sort: SearchState["sort"]): number { + if (sort === "risk_desc") { + const riskDelta = getRiskScore(right) - getRiskScore(left); + if (riskDelta !== 0) { + return riskDelta; + } + + const scoreDelta = scoreForSort(right) - scoreForSort(left); + if (scoreDelta !== 0) { + return scoreDelta; + } + + return publishedForSort(right) - publishedForSort(left); + } + if (sort === "cvss_desc") { return scoreForSort(right) - scoreForSort(left); } @@ -223,6 +257,13 @@ function scoreForSort(cve: CVESummary): number { return cve.cvss3 ?? cve.cvss ?? -1; } +function daysSincePublished(cve: CVESummary, now = Date.now()): number { + const published = publishedForSort(cve); + if (!published) return 30; + + return Math.max(0, Math.floor((now - published) / DAY_IN_MS)); +} + function publishedForSort(cve: CVESummary): number { const published = extractPublishedDate(cve); if (!published) return 0; @@ -245,6 +286,6 @@ function normalizeSeverityFilter(value: SearchState["minSeverity"] | undefined): } function normalizeSortOption(value: SearchState["sort"] | undefined): SearchState["sort"] { - const allowed: SearchState["sort"][] = ["published_desc", "published_asc", "cvss_desc", "cvss_asc"]; + const allowed: SearchState["sort"][] = ["published_desc", "published_asc", "cvss_desc", "cvss_asc", "risk_desc"]; return value && allowed.includes(value) ? value : DEFAULT_SORT; } diff --git a/src/lib/server-api.ts b/src/lib/server-api.ts index 4f3b965..80356e3 100644 --- a/src/lib/server-api.ts +++ b/src/lib/server-api.ts @@ -1,4 +1,4 @@ -import { CVEDetail, CVESummary, EPSSData, HomeDashboardData } from "./types"; +import { CVEDetail, CVESummary, EPSSData, HomeDashboardData, KnownExploitedVulnerability } from "./types"; import { SearchState } from "./search"; import { applySearchResultPreferences, @@ -9,9 +9,11 @@ import { matchesSearchState, wasPublishedWithinDays, } from "./search"; -import { parseCVEDetail, parseCVESummaryList, parseEPSSResponse } from "./validation"; +import { extractCVEId } from "./utils"; +import { parseCVEDetail, parseCVESummaryList, parseEPSSResponse, parseKnownExploitedCatalog } from "./validation"; const API_BASE = "https://vulnerability.circl.lu/api"; +const KEV_CATALOG_URL = "https://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json"; type NextFetchOptions = RequestInit & { next?: { revalidate: number } }; async function fetchUpstream(path: string): Promise { @@ -35,7 +37,7 @@ export async function getLatestCVEsServer(page: number, perPage: number): Promis const data = await fetchUpstream( `/vulnerability/?per_page=${perPage}&page=${page}&sort_order=desc&date_sort=published` ); - return parseCVESummaryList(data); + return enrichCVEsWithKev(parseCVESummaryList(data)); } export async function searchCVEsServer(params: { @@ -56,14 +58,16 @@ export async function searchCVEsServer(params: { searchParams.set("date_sort", "published"); const data = await fetchUpstream(`/vulnerability/?${searchParams.toString()}`); - return parseCVESummaryList(data); + return enrichCVEsWithKev(parseCVESummaryList(data)); } export async function getCVEByIdServer(id: string): Promise { const data = await fetchUpstream( `/vulnerability/${encodeURIComponent(id)}?with_meta=true&with_linked=true&with_comments=true` ); - return parseCVEDetail(data); + const detail = parseCVEDetail(data); + const kev = await getKnownExploitedVulnerabilityById(extractCVEId(detail)); + return kev ? { ...detail, kev } : detail; } export async function getEPSSServer(cveId: string): Promise { @@ -84,7 +88,7 @@ export async function searchByVendorProductServer( const data = await fetchUpstream( `/vulnerability/search/${encodeURIComponent(vendor)}/${encodeURIComponent(product)}?page=${page}&per_page=${perPage}` ); - return parseCVESummaryList(data); + return enrichCVEsWithKev(parseCVESummaryList(data)); } export async function getHomePageResults(state: SearchState): Promise<{ @@ -168,17 +172,17 @@ export async function getHomeDashboardData(state: SearchState): Promise wasPublishedWithinDays(cve, 7)), { ...state, minSeverity: "HIGH", - sort: "published_desc", + sort: "risk_desc", } ).slice(0, 5); @@ -196,6 +200,7 @@ export async function getHomeDashboardData(state: SearchState): Promise wasPublishedWithinDays(cve, 7)).length, + knownExploitedCount: latest.filter((cve) => Boolean(cve.kev)).length, }, presets: [ { @@ -205,9 +210,9 @@ export async function getHomeDashboardData(state: SearchState): Promise(cves: T[]): Promise { + if (cves.length === 0) { + return cves; + } + + const kevMap = await getKnownExploitedMap(); + return cves.map((cve) => { + const kev = kevMap.get(extractCVEId(cve).toUpperCase()); + return kev ? ({ ...cve, kev } satisfies T) : cve; + }); +} + +async function getKnownExploitedVulnerabilityById(cveId: string): Promise { + const kevMap = await getKnownExploitedMap(); + return kevMap.get(cveId.toUpperCase()); +} + +async function getKnownExploitedMap(): Promise> { + try { + const options: NextFetchOptions = { + headers: { + Accept: "application/json", + "User-Agent": "CVESearch-WebApp/1.0", + }, + next: { revalidate: 3600 }, + }; + const res = await fetch(KEV_CATALOG_URL, options); + + if (!res.ok) { + throw new Error(`KEV catalog error: ${res.status}`); + } + + const data = parseKnownExploitedCatalog(await res.json()); + return new Map(data.map((item) => [item.cveID.toUpperCase(), item])); + } catch { + return new Map(); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 37b8bff..805431e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,7 @@ export interface CVESummary { references?: string[]; vulnerable_product?: string[]; state?: string; + kev?: KnownExploitedVulnerability; } export interface CVEDetail { @@ -60,6 +61,21 @@ export interface CVEDetail { description?: string; }; }; + kev?: KnownExploitedVulnerability; +} + +export interface KnownExploitedVulnerability { + cveID: string; + vendorProject: string; + product: string; + vulnerabilityName: string; + dateAdded: string; + shortDescription: string; + requiredAction: string; + dueDate: string; + knownRansomwareCampaignUse?: string; + notes?: string; + cwes?: string[]; } export interface AffectedProduct { @@ -143,7 +159,7 @@ export interface SearchFilters { export type SeverityLevel = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "NONE" | "UNKNOWN"; export type SearchSeverityFilter = "ANY" | "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; -export type SearchSortOption = "published_desc" | "published_asc" | "cvss_desc" | "cvss_asc"; +export type SearchSortOption = "published_desc" | "published_asc" | "cvss_desc" | "cvss_asc" | "risk_desc"; export interface DashboardPreset { title: string; @@ -157,13 +173,14 @@ export interface DashboardSummary { criticalCount: number; highOrAboveCount: number; publishedThisWeekCount: number; + knownExploitedCount: number; } export interface HomeDashboardData { summary: DashboardSummary; presets: DashboardPreset[]; latestCritical: CVESummary[]; - highestCvss: CVESummary[]; + highestRisk: CVESummary[]; recentHighImpact: CVESummary[]; } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 3089e58..066a8cf 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,4 +1,4 @@ -import { CVEDetail, CVESummary, CWEData, EPSSData } from "./types"; +import { CVEDetail, CVESummary, CWEData, EPSSData, KnownExploitedVulnerability } from "./types"; export function parseCVESummaryList(value: unknown): CVESummary[] { if (!Array.isArray(value)) { @@ -82,6 +82,21 @@ export function parseCWEData(value: unknown): CWEData { return record as unknown as CWEData; } +export function parseKnownExploitedCatalog(value: unknown): KnownExploitedVulnerability[] { + const record = getRecord(value, "Unexpected response format: expected a KEV catalog object"); + if (!Array.isArray(record.vulnerabilities)) { + throw new Error("Unexpected response format: KEV catalog is missing vulnerabilities"); + } + + return record.vulnerabilities.flatMap((item) => { + try { + return [parseKnownExploitedVulnerability(item)]; + } catch { + return []; + } + }); +} + function parseCVESummary(value: unknown): CVESummary { const record = getRecord(value, "Unexpected response format: expected a CVE summary object"); const normalizedId = getPreferredIdentifier(record); @@ -93,6 +108,37 @@ function parseCVESummary(value: unknown): CVESummary { return normalizeRecordIdentifiers(record, normalizedId) as unknown as CVESummary; } +function parseKnownExploitedVulnerability(value: unknown): KnownExploitedVulnerability { + const record = getRecord(value, "Unexpected response format: expected a KEV vulnerability object"); + + if ( + typeof record.cveID !== "string" || + typeof record.vendorProject !== "string" || + typeof record.product !== "string" || + typeof record.vulnerabilityName !== "string" || + typeof record.dateAdded !== "string" || + typeof record.shortDescription !== "string" || + typeof record.requiredAction !== "string" || + typeof record.dueDate !== "string" + ) { + throw new Error("Unexpected response format: KEV vulnerability is missing required fields"); + } + + return { + cveID: record.cveID, + vendorProject: record.vendorProject, + product: record.product, + vulnerabilityName: record.vulnerabilityName, + dateAdded: record.dateAdded, + shortDescription: record.shortDescription, + requiredAction: record.requiredAction, + dueDate: record.dueDate, + knownRansomwareCampaignUse: typeof record.knownRansomwareCampaignUse === "string" ? record.knownRansomwareCampaignUse : undefined, + notes: typeof record.notes === "string" ? record.notes : undefined, + cwes: Array.isArray(record.cwes) ? record.cwes.filter((item): item is string => typeof item === "string") : undefined, + }; +} + function getRecord(value: unknown, message: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(message); diff --git a/tests/search.test.ts b/tests/search.test.ts index 912e035..edac8a3 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + applySearchResultPreferences, buildPresetHref, buildSearchParams, getSearchSummary, @@ -124,6 +125,39 @@ test("wasPublishedWithinDays matches recent publication windows", () => { ); }); +test("applySearchResultPreferences prioritizes KEV and exploit signals in risk-first sort", () => { + const result = applySearchResultPreferences( + [ + { + id: "CVE-2026-1000", + cvss3: 9.8, + published: "2026-03-02T00:00:00.000Z", + }, + { + id: "CVE-2026-2000", + cvss3: 7.5, + epss: 0.91, + published: "2026-03-04T00:00:00.000Z", + references: ["https://research.example/exploit-poc"], + kev: { + cveID: "CVE-2026-2000", + vendorProject: "Acme", + product: "Gateway", + vulnerabilityName: "Known exploited flaw", + dateAdded: "2026-03-05", + shortDescription: "Known exploited.", + requiredAction: "Patch now.", + dueDate: "2026-03-19", + knownRansomwareCampaignUse: "Known", + }, + }, + ], + normalizeSearchState({ sort: "risk_desc" }) + ); + + assert.equal(result[0].id, "CVE-2026-2000"); +}); + test("matchesSearchState applies text, product, CWE, severity, and date filters locally", () => { const state = normalizeSearchState({ query: "openssl", diff --git a/tests/validation.test.ts b/tests/validation.test.ts index 8f42322..ccc37ce 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -5,6 +5,7 @@ import { parseCVESummaryList, parseCWEData, parseEPSSResponse, + parseKnownExploitedCatalog, parseStringList, } from "../src/lib/validation"; @@ -79,3 +80,30 @@ test("parseEPSSResponse parses numeric strings", () => { test("parseCWEData requires an id", () => { assert.throws(() => parseCWEData({ description: "No id" }), /missing an id/); }); + +test("parseKnownExploitedCatalog parses KEV entries", () => { + const data = parseKnownExploitedCatalog({ + catalogVersion: "2026.03.06", + dateReleased: "2026-03-06T00:00:00.000Z", + count: 1, + vulnerabilities: [ + { + cveID: "CVE-2026-9999", + vendorProject: "Acme", + product: "Edge Gateway", + vulnerabilityName: "Remote Code Execution", + dateAdded: "2026-03-06", + shortDescription: "Known exploited vulnerability", + requiredAction: "Apply the vendor patch.", + dueDate: "2026-03-20", + knownRansomwareCampaignUse: "Known", + cwes: ["CWE-94"], + }, + ], + }); + + assert.equal(data.length, 1); + assert.equal(data[0].cveID, "CVE-2026-9999"); + assert.equal(data[0].knownRansomwareCampaignUse, "Known"); + assert.deepEqual(data[0].cwes, ["CWE-94"]); +}); From dedf96d69dbccf4b65be2623952a75aa1db08782 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 13:50:35 +0100 Subject: [PATCH 06/30] feat(api): add shared route guard with rate limiting and request logging --- src/app/api/ai/cve/[id]/route.ts | 48 +++-- src/app/api/ai/digest/route.ts | 30 ++- src/app/api/ai/runs/route.ts | 24 +-- src/app/api/ai/search/route.ts | 30 ++- src/app/api/projects/[id]/items/route.ts | 17 +- src/app/api/projects/[id]/route.ts | 11 +- src/app/api/projects/route.ts | 18 +- src/app/api/proxy/route.ts | 15 +- src/lib/api-route-guard.ts | 245 +++++++++++++++++++++++ tests/api-route-guard.test.ts | 65 ++++++ 10 files changed, 418 insertions(+), 85 deletions(-) create mode 100644 src/lib/api-route-guard.ts create mode 100644 tests/api-route-guard.test.ts diff --git a/src/app/api/ai/cve/[id]/route.ts b/src/app/api/ai/cve/[id]/route.ts index ba21dfe..e749074 100644 --- a/src/app/api/ai/cve/[id]/route.ts +++ b/src/app/api/ai/cve/[id]/route.ts @@ -2,29 +2,27 @@ import { NextRequest, NextResponse } from "next/server"; import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; import { listProjects } from "@/lib/projects-store"; import { generateCveInsight } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { - try { - const { id } = await context.params; - const body = await request.json().catch(() => null); - const detail = await getCVEByIdServer(decodeURIComponent(id)); - const [epss, projects] = await Promise.all([getEPSSServer(detail.id), listProjects()]); - const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); - const insight = await generateCveInsight({ - detail, - epss, - triage: body?.triage && typeof body.triage === "object" ? body.triage : null, - relatedProjects: relatedProjects.map((project) => ({ - name: project.name, - items: project.items, - updatedAt: project.updatedAt, - })), - }); - return NextResponse.json(insight); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to generate AI CVE insight" }, - { status: 500 } - ); - } -} +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + const body = await request.json().catch(() => null); + const detail = await getCVEByIdServer(decodeURIComponent(id)); + const [epss, projects] = await Promise.all([getEPSSServer(detail.id), listProjects()]); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const insight = await generateCveInsight({ + detail, + epss, + triage: body?.triage && typeof body.triage === "object" ? body.triage : null, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); + return NextResponse.json(insight); +}, { + route: "/api/ai/cve/[id]", + errorMessage: "Failed to generate AI CVE insight", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/app/api/ai/digest/route.ts b/src/app/api/ai/digest/route.ts index 6c98601..4dd26da 100644 --- a/src/app/api/ai/digest/route.ts +++ b/src/app/api/ai/digest/route.ts @@ -1,20 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { generateDigest } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest) { - try { - const body = await request.json().catch(() => null); - const digest = await generateDigest({ - watchlist: Array.isArray(body?.watchlist) ? body.watchlist : [], - alerts: Array.isArray(body?.alerts) ? body.alerts : [], - projects: Array.isArray(body?.projects) ? body.projects : [], - }); +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const digest = await generateDigest({ + watchlist: Array.isArray(body?.watchlist) ? body.watchlist : [], + alerts: Array.isArray(body?.alerts) ? body.alerts : [], + projects: Array.isArray(body?.projects) ? body.projects : [], + }); - return NextResponse.json(digest); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to generate digest" }, - { status: 500 } - ); - } -} + return NextResponse.json(digest); +}, { + route: "/api/ai/digest", + errorMessage: "Failed to generate digest", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/app/api/ai/runs/route.ts b/src/app/api/ai/runs/route.ts index 36b9641..726c418 100644 --- a/src/app/api/ai/runs/route.ts +++ b/src/app/api/ai/runs/route.ts @@ -1,16 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getRecentAIRuns } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET(request: NextRequest) { - try { - const rawLimit = request.nextUrl.searchParams.get("limit"); - const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 25; - const runs = await getRecentAIRuns(limit); - return NextResponse.json(runs); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to load AI runs" }, - { status: 500 } - ); - } -} +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const rawLimit = request.nextUrl.searchParams.get("limit"); + const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 25; + const runs = await getRecentAIRuns(limit); + return NextResponse.json(runs); +}, { + route: "/api/ai/runs", + errorMessage: "Failed to load AI runs", + rateLimit: API_RATE_LIMITS.aiRead, +}); diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index 9ff938f..fbaa146 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -1,21 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { generateSearchInterpretation } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest) { - try { - const body = await request.json().catch(() => null); - const prompt = typeof body?.prompt === "string" ? body.prompt : ""; +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const prompt = typeof body?.prompt === "string" ? body.prompt : ""; - if (!prompt.trim()) { - return NextResponse.json({ error: "prompt is required" }, { status: 400 }); - } - - const interpretation = await generateSearchInterpretation(prompt); - return NextResponse.json(interpretation); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to interpret search prompt" }, - { status: 500 } - ); + if (!prompt.trim()) { + return NextResponse.json({ error: "prompt is required" }, { status: 400 }); } -} + + const interpretation = await generateSearchInterpretation(prompt); + return NextResponse.json(interpretation); +}, { + route: "/api/ai/search", + errorMessage: "Failed to interpret search prompt", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/app/api/projects/[id]/items/route.ts b/src/app/api/projects/[id]/items/route.ts index 4fadfac..7cf7b52 100644 --- a/src/app/api/projects/[id]/items/route.ts +++ b/src/app/api/projects/[id]/items/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { addProjectItem, removeProjectItem } from "@/lib/projects-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const body = await request.json().catch(() => null); const cveId = typeof body?.cveId === "string" ? body.cveId.trim() : ""; @@ -17,9 +18,13 @@ export async function POST(request: NextRequest, context: { params: Promise<{ id } return NextResponse.json(project); -} +}, { + route: "/api/projects/[id]/items", + errorMessage: "Failed to add project item", + rateLimit: API_RATE_LIMITS.projectMutations, +}); -export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const body = await request.json().catch(() => null); const cveId = typeof body?.cveId === "string" ? body.cveId.trim() : ""; @@ -34,4 +39,8 @@ export async function DELETE(request: NextRequest, context: { params: Promise<{ } return NextResponse.json(project); -} +}, { + route: "/api/projects/[id]/items", + errorMessage: "Failed to remove project item", + rateLimit: API_RATE_LIMITS.projectMutations, +}); diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index 024a4a4..4d81589 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -1,7 +1,8 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { deleteProject } from "@/lib/projects-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { +export const DELETE = withRouteProtection(async function DELETE(_request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const success = await deleteProject(id); @@ -10,4 +11,8 @@ export async function DELETE(_request: Request, context: { params: Promise<{ id: } return NextResponse.json({ success: true }); -} +}, { + route: "/api/projects/[id]", + errorMessage: "Failed to delete project", + rateLimit: API_RATE_LIMITS.projectMutations, +}); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 07bf55a..009f5d3 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { createProject, listProjects, normalizeProjectName } from "@/lib/projects-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET() { +export const GET = withRouteProtection(async function GET(_request: NextRequest) { + void _request; const projects = await listProjects(); return NextResponse.json(projects); -} +}, { + route: "/api/projects", + errorMessage: "Failed to load projects", + rateLimit: API_RATE_LIMITS.projectReads, +}); -export async function POST(request: NextRequest) { +export const POST = withRouteProtection(async function POST(request: NextRequest) { const body = await request.json().catch(() => null); const name = typeof body?.name === "string" ? normalizeProjectName(body.name) : ""; const description = typeof body?.description === "string" ? body.description : ""; @@ -17,4 +23,8 @@ export async function POST(request: NextRequest) { const project = await createProject({ name, description }); return NextResponse.json(project, { status: 201 }); -} +}, { + route: "/api/projects", + errorMessage: "Failed to create project", + rateLimit: API_RATE_LIMITS.projectMutations, +}); diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index ffc9e76..fb4d4dd 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; const API_BASE = "https://vulnerability.circl.lu/api"; const REQUEST_TIMEOUT_MS = 10_000; +type ProxyFetchOptions = RequestInit & { next?: { revalidate: number } }; const ALLOWED_PATH_PATTERNS = [ /^\/vulnerability\/\?(.*)$/u, /^\/vulnerability\/[A-Za-z0-9._:-]+(\?.*)?$/u, @@ -12,7 +14,7 @@ const ALLOWED_PATH_PATTERNS = [ /^\/cwe\/[A-Za-z0-9._:-]+$/u, ]; -export async function GET(request: NextRequest) { +export const GET = withRouteProtection(async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const path = searchParams.get("path"); @@ -30,14 +32,15 @@ export async function GET(request: NextRequest) { const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - const res = await fetch(targetUrl, { + const options: ProxyFetchOptions = { headers: { Accept: "application/json", "User-Agent": "CVESearch-WebApp/1.0", }, next: { revalidate: 60 }, signal: controller.signal, - }); + }; + const res = await fetch(targetUrl, options); if (!res.ok) { return NextResponse.json( @@ -63,7 +66,11 @@ export async function GET(request: NextRequest) { } finally { clearTimeout(timeout); } -} +}, { + route: "/api/proxy", + errorMessage: "Proxy error", + rateLimit: API_RATE_LIMITS.proxy, +}); function isAllowedPath(path: string): boolean { return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(path)); diff --git a/src/lib/api-route-guard.ts b/src/lib/api-route-guard.ts new file mode 100644 index 0000000..0b31f75 --- /dev/null +++ b/src/lib/api-route-guard.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { NextResponse } from "next/server"; + +interface RateLimitWindow { + count: number; + resetAt: number; +} + +export interface APIRequestLogRecord { + id: string; + route: string; + method: string; + status: number; + durationMs: number; + limited: boolean; + clientId: string; + error: string; + createdAt: string; +} + +interface RateLimitConfig { + bucket: string; + maxRequests: number; + windowMs: number; +} + +interface RouteProtectionConfig { + errorMessage: string; + rateLimit: RateLimitConfig; + route: string; +} + +type RouteHandler = (...args: T) => Promise | Response; + +const DATA_DIR = path.join(process.cwd(), "data"); +const API_REQUEST_LOG_FILE = () => process.env.API_REQUEST_LOG_FILE?.trim() || path.join(DATA_DIR, "api-requests.json"); +const MAX_STORED_REQUEST_LOGS = 500; +const rateLimitState = new Map(); + +export function withRouteProtection(handler: RouteHandler, config: RouteProtectionConfig): RouteHandler { + return async (...args: T) => { + const request = args[0]; + const startedAt = Date.now(); + const clientId = getClientIdentifier(request); + const limit = consumeRateLimit(clientId, config.rateLimit); + + if (limit.limited) { + const response = withRateLimitHeaders( + NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 }), + config.rateLimit, + limit.remaining, + limit.resetAt + ); + await appendAPIRequestLog({ + route: config.route, + method: request.method, + status: 429, + durationMs: Date.now() - startedAt, + limited: true, + clientId, + error: "rate_limit_exceeded", + }); + return response; + } + + try { + const response = await handler(...args); + const nextResponse = withRateLimitHeaders(response, config.rateLimit, limit.remaining, limit.resetAt); + await appendAPIRequestLog({ + route: config.route, + method: request.method, + status: nextResponse.status, + durationMs: Date.now() - startedAt, + limited: false, + clientId, + error: "", + }); + return nextResponse; + } catch (error) { + const response = withRateLimitHeaders( + NextResponse.json( + { error: error instanceof Error ? error.message : config.errorMessage }, + { status: 500 } + ), + config.rateLimit, + limit.remaining, + limit.resetAt + ); + await appendAPIRequestLog({ + route: config.route, + method: request.method, + status: 500, + durationMs: Date.now() - startedAt, + limited: false, + clientId, + error: error instanceof Error ? error.message : config.errorMessage, + }); + return response; + } + }; +} + +export async function listRecentAPIRequestLogs(limit = 50): Promise { + const records = await readAPIRequestLogs(); + return records.slice(0, normalizeLimit(limit)); +} + +export function resetAPIRateLimits(): void { + rateLimitState.clear(); +} + +function consumeRateLimit(clientId: string, config: RateLimitConfig): { limited: boolean; remaining: number; resetAt: number } { + const now = Date.now(); + const key = `${config.bucket}:${clientId}`; + const current = rateLimitState.get(key); + + if (!current || current.resetAt <= now) { + const next: RateLimitWindow = { + count: 1, + resetAt: now + config.windowMs, + }; + rateLimitState.set(key, next); + return { + limited: false, + remaining: Math.max(config.maxRequests - next.count, 0), + resetAt: next.resetAt, + }; + } + + if (current.count >= config.maxRequests) { + return { + limited: true, + remaining: 0, + resetAt: current.resetAt, + }; + } + + current.count += 1; + rateLimitState.set(key, current); + return { + limited: false, + remaining: Math.max(config.maxRequests - current.count, 0), + resetAt: current.resetAt, + }; +} + +function withRateLimitHeaders(response: Response, config: RateLimitConfig, remaining: number, resetAt: number): Response { + const nextResponse = response instanceof NextResponse ? response : new NextResponse(response.body, response); + nextResponse.headers.set("X-RateLimit-Limit", String(config.maxRequests)); + nextResponse.headers.set("X-RateLimit-Remaining", String(Math.max(remaining, 0))); + nextResponse.headers.set("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000))); + return nextResponse; +} + +function getClientIdentifier(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + const realIp = request.headers.get("x-real-ip")?.trim(); + const userAgent = request.headers.get("user-agent")?.trim() || "unknown-agent"; + const seed = `${forwarded || realIp || "local"}:${userAgent}`; + return createHash("sha256").update(seed).digest("hex").slice(0, 16); +} + +async function appendAPIRequestLog(input: Omit): Promise { + try { + const records = await readAPIRequestLogs(); + const next: APIRequestLogRecord[] = [ + { + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + ...input, + }, + ...records, + ].slice(0, MAX_STORED_REQUEST_LOGS); + await fs.mkdir(path.dirname(API_REQUEST_LOG_FILE()), { recursive: true }); + await fs.writeFile(API_REQUEST_LOG_FILE(), JSON.stringify(next, null, 2)); + } catch { + } +} + +async function readAPIRequestLogs(): Promise { + try { + const raw = await fs.readFile(API_REQUEST_LOG_FILE(), "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isAPIRequestLogRecord) : []; + } catch { + return []; + } +} + +function isAPIRequestLogRecord(value: unknown): value is APIRequestLogRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.route === "string" && + typeof record.method === "string" && + typeof record.status === "number" && + typeof record.durationMs === "number" && + typeof record.limited === "boolean" && + typeof record.clientId === "string" && + typeof record.error === "string" && + typeof record.createdAt === "string" + ); +} + +function normalizeLimit(limit: number): number { + if (!Number.isFinite(limit)) { + return 50; + } + + return Math.min(Math.max(Math.floor(limit), 1), 200); +} + +export const API_RATE_LIMITS = { + aiRead: { + bucket: "ai-read", + maxRequests: 30, + windowMs: 60_000, + }, + aiWrite: { + bucket: "ai-write", + maxRequests: 12, + windowMs: 60_000, + }, + projectMutations: { + bucket: "project-mutations", + maxRequests: 40, + windowMs: 60_000, + }, + projectReads: { + bucket: "project-reads", + maxRequests: 120, + windowMs: 60_000, + }, + proxy: { + bucket: "proxy", + maxRequests: 90, + windowMs: 60_000, + }, +} as const; diff --git a/tests/api-route-guard.test.ts b/tests/api-route-guard.test.ts new file mode 100644 index 0000000..4ddf3bf --- /dev/null +++ b/tests/api-route-guard.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { NextResponse } from "next/server"; +import { + listRecentAPIRequestLogs, + resetAPIRateLimits, + withRouteProtection, +} from "../src/lib/api-route-guard"; + +test("withRouteProtection enforces rate limits and records request logs", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cvesearch-api-logs-")); + const previous = process.env.API_REQUEST_LOG_FILE; + process.env.API_REQUEST_LOG_FILE = path.join(tempDir, "api-requests.json"); + resetAPIRateLimits(); + + const handler = withRouteProtection( + async function GET(request: Request) { + const url = new URL(request.url); + return NextResponse.json({ ok: url.searchParams.get("value") ?? "missing" }); + }, + { + route: "/api/test", + errorMessage: "Failed", + rateLimit: { + bucket: "test", + maxRequests: 1, + windowMs: 60_000, + }, + } + ); + + try { + const first = await handler(new Request("https://example.test/api/test?value=one", { + method: "GET", + headers: { "user-agent": "test-agent" }, + })); + const second = await handler(new Request("https://example.test/api/test?value=two", { + method: "GET", + headers: { "user-agent": "test-agent" }, + })); + + assert.equal(first.status, 200); + assert.equal(second.status, 429); + assert.equal(first.headers.get("X-RateLimit-Limit"), "1"); + assert.equal(second.headers.get("X-RateLimit-Remaining"), "0"); + + const logs = await listRecentAPIRequestLogs(10); + assert.equal(logs.length, 2); + assert.equal(logs[0].status, 429); + assert.equal(logs[0].limited, true); + assert.equal(logs[1].status, 200); + assert.equal(logs[1].route, "/api/test"); + } finally { + resetAPIRateLimits(); + if (previous === undefined) { + delete process.env.API_REQUEST_LOG_FILE; + } else { + process.env.API_REQUEST_LOG_FILE = previous; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); From cd017cc8b838ce0581566c6e0cef96854c2f7321 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 13:57:40 +0100 Subject: [PATCH 07/30] feat(audit): add project and triage activity history --- docs/todo.md | 32 ++++++------ src/components/ProjectsPageClient.tsx | 16 ++++++ src/components/TriagePanel.tsx | 16 ++++++ src/lib/projects-store.ts | 73 ++++++++++++++++++++++++--- src/lib/triage.ts | 60 +++++++++++++++++++++- src/lib/types.ts | 8 +++ tests/projects-store.test.ts | 31 +++++++++++- tests/triage.test.ts | 26 +++++++++- 8 files changed, 235 insertions(+), 27 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 26efd91..be01670 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,8 +7,8 @@ - replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) - add authentication and authorization - move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence -- stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage -- replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows +- [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage +- [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows - evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js - define a small tool registry for agent workflows - add prompt and version management so changes to agent behavior are explicit and reversible @@ -23,12 +23,12 @@ ### Build Next -- persist AI runs, prompts, outputs, tool calls, and failures for debugging and review -- add per-feature model/provider configuration instead of one shared global setting for every AI flow -- add audit fields and activity history for project and triage changes -- add rate limiting and request logging for API routes -- enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals -- improve result cards with stronger severity, EPSS, KEV, and recency cues +- [x] persist AI runs, prompts, outputs, tool calls, and failures for debugging and review +- [x] add per-feature model/provider configuration instead of one shared global setting for every AI flow +- [x] add audit fields and activity history for project and triage changes +- [x] add rate limiting and request logging for API routes +- [x] enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals +- [x] improve result cards with stronger severity, EPSS, KEV, and recency cues - add bulk actions for watchlist, triage, and project assignment - add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review @@ -66,14 +66,14 @@ - replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) - add authentication and authorization - move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence -- add audit fields and activity history for project and triage changes -- add rate limiting and request logging for API routes +- [x] add audit fields and activity history for project and triage changes +- [x] add rate limiting and request logging for API routes ## Product and UX - integrate Radix UI primitives/theme for a more consistent UI system - add richer dashboard views for analysts, maintainers, and incident response workflows -- improve result cards with stronger severity, EPSS, KEV, and recency cues +- [x] improve result cards with stronger severity, EPSS, KEV, and recency cues - add better empty states, skeleton states, and success/error feedback across the app - add bulk actions for watchlist, triage, and project assignment - add import/export for projects, triage state, saved views, and watchlists @@ -83,12 +83,12 @@ - expand project management with owners, due dates, labels, status, and timeline views - add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions - add asset or product inventory mapping so CVEs can be linked to affected internal systems -- enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals +- [x] enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals - add team-facing notifications and scheduled digest delivery ## AI and Agent Platform -- replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows +- [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows - evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js - define a small tool registry for agent workflows: - search CVEs @@ -97,9 +97,9 @@ - read alert rule matches - read and update project records - read and update triage state -- persist AI runs, prompts, outputs, tool calls, and failures for debugging and review +- [x] persist AI runs, prompts, outputs, tool calls, and failures for debugging and review - add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality -- add per-feature model/provider configuration instead of one shared global setting for every AI flow +- [x] add per-feature model/provider configuration instead of one shared global setting for every AI flow ## AI Features to Add @@ -116,7 +116,7 @@ ## AI Safety and Operations -- stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage +- [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage - add usage tracking, latency metrics, and cost visibility for each AI feature - add prompt and version management so changes to agent behavior are explicit and reversible - add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses diff --git a/src/components/ProjectsPageClient.tsx b/src/components/ProjectsPageClient.tsx index 280b386..db11487 100644 --- a/src/components/ProjectsPageClient.tsx +++ b/src/components/ProjectsPageClient.tsx @@ -121,6 +121,22 @@ export default function ProjectsPageClient() {
    )} + {project.activity.length > 0 && ( +
    +

    Recent activity

    +
    + {project.activity.slice(0, 5).map((entry) => ( +
    +

    {entry.summary}

    + + {new Date(entry.createdAt).toLocaleString("en-US")} + +
    + ))} +
    +
    + )} +
    ))} diff --git a/src/components/TriagePanel.tsx b/src/components/TriagePanel.tsx index f4107cd..ef273ef 100644 --- a/src/components/TriagePanel.tsx +++ b/src/components/TriagePanel.tsx @@ -100,6 +100,22 @@ export default function TriagePanel({ cveId }: { cveId: string }) { Last updated {new Date(record.updatedAt).toLocaleString("en-US")}

    )} + + {record.activity.length > 0 && ( +
    +

    Recent activity

    +
    + {record.activity.slice(0, 6).map((entry) => ( +
    +

    {entry.summary}

    +

    + {new Date(entry.createdAt).toLocaleString("en-US")} +

    +
    + ))} +
    +
    + )}
    ); } diff --git a/src/lib/projects-store.ts b/src/lib/projects-store.ts index d722573..7e089a2 100644 --- a/src/lib/projects-store.ts +++ b/src/lib/projects-store.ts @@ -1,9 +1,13 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { ProjectItem, ProjectRecord } from "./types"; +import { AuditLogEntry, ProjectItem, ProjectRecord } from "./types"; const DATA_DIR = path.join(process.cwd(), "data"); -const PROJECTS_FILE = path.join(DATA_DIR, "projects.json"); +const MAX_ACTIVITY_ENTRIES = 20; + +function getProjectsFile(): string { + return process.env.PROJECTS_FILE?.trim() || path.join(DATA_DIR, "projects.json"); +} export async function listProjects(): Promise { const projects = await readProjects(); @@ -23,6 +27,7 @@ export async function createProject(input: { createdAt: now, updatedAt: now, items: [], + activity: [createActivityEntry("project_created", `Created project ${normalizeProjectName(input.name)}`, now)], }; projects.push(project); @@ -45,17 +50,25 @@ export async function addProjectItem(projectId: string, item: { cveId: string; n const project = projects[index]; const now = new Date().toISOString(); + const note = item.note?.trim() ?? ""; const exists = project.items.some((entry) => entry.cveId === item.cveId); const nextItems: ProjectItem[] = exists ? project.items.map((entry) => - entry.cveId === item.cveId ? { ...entry, note: item.note ?? entry.note, addedAt: entry.addedAt } : entry + entry.cveId === item.cveId ? { ...entry, note: note || entry.note, addedAt: entry.addedAt } : entry ) - : [{ cveId: item.cveId, note: item.note?.trim() ?? "", addedAt: now }, ...project.items]; + : [{ cveId: item.cveId, note, addedAt: now }, ...project.items]; + + const summary = exists + ? note && note !== project.items.find((entry) => entry.cveId === item.cveId)?.note + ? `Updated note for ${item.cveId}` + : `Refreshed ${item.cveId} in project` + : `Added ${item.cveId} to project`; const updated: ProjectRecord = { ...project, items: nextItems, updatedAt: now, + activity: appendActivity(project.activity, createActivityEntry(exists ? "project_item_updated" : "project_item_added", summary, now)), }; projects[index] = updated; await writeProjects(projects); @@ -69,10 +82,12 @@ export async function removeProjectItem(projectId: string, cveId: string): Promi const project = projects[index]; const nextItems = project.items.filter((item) => item.cveId !== cveId); + const now = new Date().toISOString(); const updated: ProjectRecord = { ...project, items: nextItems, - updatedAt: new Date().toISOString(), + updatedAt: now, + activity: appendActivity(project.activity, createActivityEntry("project_item_removed", `Removed ${cveId} from project`, now)), }; projects[index] = updated; await writeProjects(projects); @@ -85,9 +100,9 @@ export function normalizeProjectName(name: string): string { async function readProjects(): Promise { try { - const raw = await fs.readFile(PROJECTS_FILE, "utf8"); + const raw = await fs.readFile(getProjectsFile(), "utf8"); const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(isProjectRecord) : []; + return Array.isArray(parsed) ? parsed.filter(isProjectRecord).map(normalizeProjectRecord) : []; } catch { return []; } @@ -95,7 +110,7 @@ async function readProjects(): Promise { async function writeProjects(projects: ProjectRecord[]): Promise { await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(PROJECTS_FILE, JSON.stringify(projects, null, 2)); + await fs.writeFile(getProjectsFile(), JSON.stringify(projects, null, 2)); } function isProjectRecord(value: unknown): value is ProjectRecord { @@ -111,3 +126,45 @@ function isProjectRecord(value: unknown): value is ProjectRecord { Array.isArray(record.items) ); } + +function normalizeProjectRecord(record: ProjectRecord): ProjectRecord { + return { + id: record.id, + name: record.name, + description: record.description, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + items: Array.isArray(record.items) ? record.items.filter(isProjectItem) : [], + activity: Array.isArray(record.activity) ? record.activity.filter(isAuditLogEntry).slice(0, MAX_ACTIVITY_ENTRIES) : [], + }; +} + +function isProjectItem(value: unknown): value is ProjectItem { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return typeof record.cveId === "string" && typeof record.addedAt === "string" && (typeof record.note === "string" || record.note === undefined); +} + +function isAuditLogEntry(value: unknown): value is AuditLogEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.action === "string" && + typeof record.summary === "string" && + typeof record.createdAt === "string" + ); +} + +function createActivityEntry(action: string, summary: string, createdAt: string): AuditLogEntry { + return { + id: crypto.randomUUID(), + action, + summary, + createdAt, + }; +} + +function appendActivity(activity: AuditLogEntry[], entry: AuditLogEntry): AuditLogEntry[] { + return [entry, ...activity].slice(0, MAX_ACTIVITY_ENTRIES); +} diff --git a/src/lib/triage.ts b/src/lib/triage.ts index 2c2c26b..f5ac9d6 100644 --- a/src/lib/triage.ts +++ b/src/lib/triage.ts @@ -1,5 +1,8 @@ +import { AuditLogEntry } from "./types"; + export const TRIAGE_UPDATED_EVENT = "cvesearch:triage-updated"; const TRIAGE_STORAGE_KEY = "cvesearch.triage"; +const MAX_TRIAGE_ACTIVITY = 20; export type TriageStatus = "new" | "investigating" | "mitigated" | "accepted" | "closed"; @@ -10,6 +13,7 @@ export interface TriageRecord { notes: string; tags: string[]; updatedAt: string; + activity: AuditLogEntry[]; } export function readTriageMap(): Record { @@ -41,9 +45,16 @@ export function readTriageRecord(cveId: string): TriageRecord { export function writeTriageRecord(record: TriageRecord): TriageRecord { if (typeof window === "undefined") return record; + const previous = readTriageRecord(record.cveId); + const nextRecord = normalizeTriageRecord(record); + const nextActivity = buildTriageActivity(previous, nextRecord); + const next = { ...readTriageMap(), - [record.cveId]: normalizeTriageRecord(record), + [record.cveId]: { + ...nextRecord, + activity: nextActivity, + }, }; window.localStorage.setItem(TRIAGE_STORAGE_KEY, JSON.stringify(next)); @@ -60,9 +71,29 @@ export function createDefaultTriageRecord(cveId: string): TriageRecord { notes: "", tags: [], updatedAt: "", + activity: [], }; } +export function summarizeTriageChanges(previous: TriageRecord, next: TriageRecord): string[] { + const changes: string[] = []; + + if (previous.status !== next.status) { + changes.push(`Status changed to ${getTriageStatusLabel(next.status)}`); + } + if (previous.owner !== next.owner) { + changes.push(next.owner ? `Owner set to ${next.owner}` : "Owner cleared"); + } + if (previous.notes !== next.notes) { + changes.push(next.notes ? "Notes updated" : "Notes cleared"); + } + if (previous.tags.join("|") !== next.tags.join("|")) { + changes.push(next.tags.length > 0 ? `Tags updated: ${next.tags.join(", ")}` : "Tags cleared"); + } + + return changes; +} + export function parseTags(value: string): string[] { return Array.from( new Set( @@ -125,9 +156,36 @@ function normalizeTriageRecord(record: TriageRecord): TriageRecord { notes: record.notes ?? "", tags: Array.isArray(record.tags) ? record.tags.filter((tag): tag is string => typeof tag === "string") : [], updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : "", + activity: Array.isArray(record.activity) ? record.activity.filter(isAuditLogEntry).slice(0, MAX_TRIAGE_ACTIVITY) : [], }; } function isValidStatus(status: string): status is TriageStatus { return ["new", "investigating", "mitigated", "accepted", "closed"].includes(status); } + +function isAuditLogEntry(value: unknown): value is AuditLogEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.action === "string" && + typeof record.summary === "string" && + typeof record.createdAt === "string" + ); +} + +function buildTriageActivity(previous: TriageRecord, next: TriageRecord): AuditLogEntry[] { + const changes = summarizeTriageChanges(previous, next); + if (changes.length === 0 || !next.updatedAt) { + return previous.activity; + } + + const entry: AuditLogEntry = { + id: crypto.randomUUID(), + action: "triage_updated", + summary: changes.join(" • "), + createdAt: next.updatedAt, + }; + return [entry, ...previous.activity].slice(0, MAX_TRIAGE_ACTIVITY); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 805431e..edd44b4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -190,6 +190,13 @@ export interface ProjectItem { addedAt: string; } +export interface AuditLogEntry { + id: string; + action: string; + summary: string; + createdAt: string; +} + export interface ProjectRecord { id: string; name: string; @@ -197,6 +204,7 @@ export interface ProjectRecord { createdAt: string; updatedAt: string; items: ProjectItem[]; + activity: AuditLogEntry[]; } export interface AITriageContextSnapshot { diff --git a/tests/projects-store.test.ts b/tests/projects-store.test.ts index 66048df..d14c3fe 100644 --- a/tests/projects-store.test.ts +++ b/tests/projects-store.test.ts @@ -1,7 +1,36 @@ import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import { normalizeProjectName } from "../src/lib/projects-store"; +import { addProjectItem, createProject, normalizeProjectName, removeProjectItem } from "../src/lib/projects-store"; test("normalizeProjectName trims and collapses whitespace", () => { assert.equal(normalizeProjectName(" Incident Alpha "), "Incident Alpha"); }); + +test("project store records bounded activity history for create and item changes", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cvesearch-projects-")); + const previous = process.env.PROJECTS_FILE; + process.env.PROJECTS_FILE = path.join(tempDir, "projects.json"); + + try { + const project = await createProject({ name: "Incident Alpha" }); + const withItem = await addProjectItem(project.id, { cveId: "CVE-2026-1111", note: "Investigate first" }); + const withoutItem = await removeProjectItem(project.id, "CVE-2026-1111"); + + assert.ok(withItem); + assert.ok(withoutItem); + assert.equal(project.activity[0]?.action, "project_created"); + assert.equal(withItem?.activity[0]?.action, "project_item_added"); + assert.equal(withoutItem?.activity[0]?.action, "project_item_removed"); + assert.equal(withoutItem?.activity.length, 3); + } finally { + if (previous === undefined) { + delete process.env.PROJECTS_FILE; + } else { + process.env.PROJECTS_FILE = previous; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/tests/triage.test.ts b/tests/triage.test.ts index 92b8f25..7215cc6 100644 --- a/tests/triage.test.ts +++ b/tests/triage.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getTriageStatusLabel, parseTags } from "../src/lib/triage"; +import { createDefaultTriageRecord, getTriageStatusLabel, parseTags, summarizeTriageChanges } from "../src/lib/triage"; test("parseTags normalizes comma-separated tag input", () => { assert.deepEqual(parseTags(" internet-facing, patch-window, internet-facing "), [ @@ -14,3 +14,27 @@ test("getTriageStatusLabel returns user-facing labels", () => { assert.equal(getTriageStatusLabel("investigating"), "Investigating"); assert.equal(getTriageStatusLabel("accepted"), "Accepted Risk"); }); + +test("createDefaultTriageRecord starts with empty activity history", () => { + const record = createDefaultTriageRecord("CVE-2026-0001"); + assert.deepEqual(record.activity, []); +}); + +test("summarizeTriageChanges describes meaningful field updates", () => { + const previous = createDefaultTriageRecord("CVE-2026-0001"); + const next = { + ...previous, + status: "investigating" as const, + owner: "secops", + notes: "Internet-facing service", + tags: ["internet-facing", "patch-window"], + updatedAt: "2026-03-06T12:00:00.000Z", + }; + + assert.deepEqual(summarizeTriageChanges(previous, next), [ + "Status changed to Investigating", + "Owner set to secops", + "Notes updated", + "Tags updated: internet-facing, patch-window", + ]); +}); From b2aea8b1abab15de2500b02f01ca0d4bc8ec784f Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Fri, 6 Mar 2026 14:08:35 +0100 Subject: [PATCH 08/30] docs: mark completed AI search and validation tasks as done --- docs/todo.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index be01670..2d8d6ab 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -14,12 +14,12 @@ - add prompt and version management so changes to agent behavior are explicit and reversible - add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality - add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses -- upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches +- [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches - add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions - add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action -- add search explanation output that shows exactly which fields, filters, and assumptions the AI applied +- [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied - improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata -- add stronger schema validation around upstream CIRCL payloads and AI-generated JSON +- [x] add stronger schema validation around upstream CIRCL payloads and AI-generated JSON ### Build Next @@ -33,7 +33,7 @@ - add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review - add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats -- add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view +- [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view - add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications - add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default @@ -103,13 +103,13 @@ ## AI Features to Add -- upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches +- [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches - add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions - add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review - add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action -- add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view +- [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view - add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact - add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches - add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications @@ -126,6 +126,6 @@ - expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent - add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" -- add search explanation output that shows exactly which fields, filters, and assumptions the AI applied +- [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied - improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata -- add stronger schema validation around upstream CIRCL payloads and AI-generated JSON +- [x] add stronger schema validation around upstream CIRCL payloads and AI-generated JSON From 1dabfa4599524e8a96ad50c7a82e7f72101893e9 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 15:51:46 +0100 Subject: [PATCH 09/30] fix(repos): harden GitHub monitoring and fix flows Protect the GitHub repo APIs, keep dependency scans branch-accurate, and preserve manifest locations so monorepo fixes target the correct files. Add regression coverage for parser, PR pagination, and monitored-repo persistence to prevent silent scan and remediation failures. --- docs/todo.md | 13 +++ src/app/api/github/fix/route.ts | 143 +++++++++++++++++++++-- src/app/api/github/monitored/route.ts | 43 ++++--- src/app/api/github/repos/route.ts | 12 +- src/app/api/github/scan/route.ts | 21 ++-- src/components/ReposPageClient.tsx | 7 +- src/components/VulnerabilityFixModal.tsx | 5 + src/lib/ai-fix.ts | 7 +- src/lib/api-route-guard.ts | 15 +++ src/lib/dependency-parser.ts | 63 ++++++++-- src/lib/github-pr.ts | 43 ++++--- src/lib/github-types.ts | 3 + src/lib/github.ts | 56 +++++---- src/lib/monitored-repos-store.ts | 121 ++++++++++++------- src/lib/osv.ts | 2 +- tests/dependency-parser.test.ts | 52 +++++++++ tests/github-pr.test.ts | 62 ++++++++++ tests/github.test.ts | 110 +++++++++++++++++ tests/monitored-repos-store.test.ts | 34 ++++++ 19 files changed, 686 insertions(+), 126 deletions(-) create mode 100644 tests/dependency-parser.test.ts create mode 100644 tests/github-pr.test.ts create mode 100644 tests/github.test.ts create mode 100644 tests/monitored-repos-store.test.ts diff --git a/docs/todo.md b/docs/todo.md index 2d8d6ab..b07a2fa 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,5 +1,10 @@ # Todo +## misc stuff + +- [ ] make the UI use 95% of the screen width + + ## Recommended Build Order ### Build First @@ -7,6 +12,11 @@ - replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) - add authentication and authorization - move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence +- [x] harden GitHub repository monitoring routes with the same rate limiting and request logging used by the rest of the API +- [x] make GitHub dependency scans branch-accurate and fail closed on tree truncation or dependency file fetch errors +- [x] preserve dependency manifest location through scan and fix flows so monorepo remediation targets the correct workspace +- [x] constrain AI-generated fix PR file writes to server-validated repository files only +- [x] add regression tests for dependency parsing and GitHub scan edge cases - [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage - [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows - evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js @@ -66,6 +76,7 @@ - replace JSON file persistence with a real database (`sqlite` first, with a clean path to Postgres later) - add authentication and authorization - move browser-local workflow data (watchlist, alerts, triage, saved views) into user-scoped server persistence +- [x] harden GitHub repository monitoring routes with the same rate limiting and request logging used by the rest of the API - [x] add audit fields and activity history for project and triage changes - [x] add rate limiting and request logging for API routes @@ -126,6 +137,8 @@ - expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent - add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" +- [x] preserve dependency manifest location through scan and fix flows so monorepo remediation targets the correct workspace +- [x] add regression tests for dependency parsing and GitHub scan edge cases - [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied - improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata - [x] add stronger schema validation around upstream CIRCL payloads and AI-generated JSON diff --git a/src/app/api/github/fix/route.ts b/src/app/api/github/fix/route.ts index d01f72a..6480250 100644 --- a/src/app/api/github/fix/route.ts +++ b/src/app/api/github/fix/route.ts @@ -18,13 +18,20 @@ import { generateVulnerabilityFix, extractFixedVersion } from "@/lib/ai-fix"; import { FixRequestPayload, FixResponse, + FixFileChange, + ParsedDependency, RepoFileContent, } from "@/lib/github-types"; import { AISettings, AIProvider } from "@/lib/types"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; const MAX_SOURCE_FILES = 5; +const MAX_ALLOWED_FILE_CHANGES = 8; +const MAX_FILE_CONTENT_BYTES = 200_000; +const VALID_PROVIDERS: AIProvider[] = ["heuristic", "openai", "anthropic"]; +const REPO_FULL_NAME_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; -export async function POST(request: NextRequest) { +export const POST = withRouteProtection(async function POST(request: NextRequest) { if (!isGitHubTokenConfigured()) { return NextResponse.json( { error: "GITHUB_TOKEN is not configured" }, @@ -33,20 +40,33 @@ export async function POST(request: NextRequest) { } try { - const body: FixRequestPayload = await request.json(); + const body: FixRequestPayload | null = await request.json().catch(() => null); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName.trim() : ""; + const vulnerability = body?.vulnerability; + const matchedDependency = body?.matchedDependency; - if (!body.repoFullName || !body.vulnerability || !body.matchedDependency) { + if (!repoFullName || !REPO_FULL_NAME_PATTERN.test(repoFullName) || !vulnerability || !isValidMatchedDependency(matchedDependency)) { return NextResponse.json( { error: "Missing required fields: repoFullName, vulnerability, matchedDependency" }, { status: 400 } ); } - const { repoFullName, vulnerability, matchedDependency, aiSettings } = body; + const aiSettings = body?.aiSettings; const fixedVersion = extractFixedVersion(vulnerability, matchedDependency.name); - const dependencyFiles = await fetchRepoDependencyFiles(repoFullName); + const dependencyFiles = selectRelevantDependencyFiles( + await fetchRepoDependencyFiles(repoFullName), + matchedDependency + ); + + if (dependencyFiles.length === 0) { + return NextResponse.json( + { error: "Could not locate the dependency manifest for this package in the repository." }, + { status: 422 } + ); + } const sourceFiles: RepoFileContent[] = []; try { @@ -83,6 +103,19 @@ export async function POST(request: NextRequest) { aiSettings ? normalizeAISettingsFromRequest(aiSettings) : undefined ); + try { + validateFixFileChanges( + fixResult.fileChanges, + new Set([...dependencyFiles, ...sourceFiles].map((file) => file.path)) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid generated fix changes"; + return NextResponse.json( + { error: message, analysis: fixResult.analysis }, + { status: 422 } + ); + } + if (fixResult.fileChanges.length === 0) { return NextResponse.json( { @@ -213,9 +246,11 @@ export async function POST(request: NextRequest) { console.error(`[fix-route] Unexpected error:`, message); return NextResponse.json({ error: message }, { status: 500 }); } -} - -const VALID_PROVIDERS: AIProvider[] = ["heuristic", "openai", "anthropic"]; +}, { + route: "/api/github/fix", + errorMessage: "Failed to create GitHub fix pull request", + rateLimit: API_RATE_LIMITS.githubWrites, +}); const normalizeAISettingsFromRequest = ( raw: Record @@ -230,3 +265,95 @@ const normalizeAISettingsFromRequest = ( apiKey: typeof raw.apiKey === "string" ? raw.apiKey : undefined, }; }; + +const isValidMatchedDependency = (value: unknown): value is ParsedDependency => { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + const manifestPath = typeof record.manifestPath === "string" ? record.manifestPath : undefined; + const lockfilePath = typeof record.lockfilePath === "string" ? record.lockfilePath : undefined; + + return ( + typeof record.name === "string" && + typeof record.version === "string" && + typeof record.isDev === "boolean" && + (record.ecosystem === "npm" || record.ecosystem === "Packagist") && + isSafeRepoPath(manifestPath) && + isSafeRepoPath(lockfilePath) + ); +}; + +const isSafeRepoPath = (value: string | undefined): boolean => { + if (value === undefined) { + return true; + } + + return value.length > 0 && !value.startsWith("/") && !value.includes("\\") && !/(^|\/)\.\.(\/|$)/.test(value); +}; + +const getParentDir = (filePath: string | undefined): string => { + if (!filePath) { + return ""; + } + + const lastSlash = filePath.lastIndexOf("/"); + return lastSlash === -1 ? "" : filePath.slice(0, lastSlash); +}; + +const selectRelevantDependencyFiles = ( + files: RepoFileContent[], + dependency: ParsedDependency +): RepoFileContent[] => { + const preferredPaths = new Set( + [dependency.manifestPath, dependency.lockfilePath].filter((value): value is string => Boolean(value)) + ); + + if (preferredPaths.size > 0) { + return files.filter((file) => preferredPaths.has(file.path)); + } + + const sourceDirectory = dependency.sourceDirectory ?? getParentDir(dependency.manifestPath) ?? getParentDir(dependency.lockfilePath); + if (!sourceDirectory) { + return files; + } + + return files.filter((file) => getParentDir(file.path) === sourceDirectory); +}; + +const validateFixFileChanges = (fileChanges: FixFileChange[], allowedPaths: Set): void => { + if (fileChanges.length > MAX_ALLOWED_FILE_CHANGES) { + throw new Error(`Generated fix exceeds the maximum of ${MAX_ALLOWED_FILE_CHANGES} files`); + } + + const seenPaths = new Set(); + + for (const change of fileChanges) { + if (!isSafeRepoPath(change.path)) { + throw new Error(`Generated fix contains an unsafe path: ${change.path}`); + } + + if (!allowedPaths.has(change.path)) { + throw new Error(`Generated fix tried to modify an unapproved file: ${change.path}`); + } + + if (isLockfilePath(change.path)) { + throw new Error(`Generated fix tried to modify a lock file: ${change.path}`); + } + + if (seenPaths.has(change.path)) { + throw new Error(`Generated fix contains duplicate file changes for ${change.path}`); + } + + seenPaths.add(change.path); + + if (Buffer.byteLength(change.content, "utf8") > MAX_FILE_CONTENT_BYTES) { + throw new Error(`Generated fix content is too large for ${change.path}`); + } + } +}; + +const isLockfilePath = (filePath: string): boolean => { + return filePath.endsWith("package-lock.json") || filePath.endsWith("pnpm-lock.yaml") || filePath.endsWith("composer.lock"); +}; diff --git a/src/app/api/github/monitored/route.ts b/src/app/api/github/monitored/route.ts index ee7fc28..f0a6eb0 100644 --- a/src/app/api/github/monitored/route.ts +++ b/src/app/api/github/monitored/route.ts @@ -4,8 +4,12 @@ import { addMonitoredRepo, removeMonitoredRepo, } from "@/lib/monitored-repos-store"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET() { +const isRepoFullName = (value: string): boolean => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); + +export const GET = withRouteProtection(async function GET(_request: NextRequest) { + void _request; try { const repos = await listMonitoredRepos(); return NextResponse.json(repos); @@ -13,22 +17,27 @@ export async function GET() { const message = error instanceof Error ? error.message : "Failed to list monitored repos"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}, { + route: "/api/github/monitored", + errorMessage: "Failed to list monitored repositories", + rateLimit: API_RATE_LIMITS.githubReads, +}); -export async function POST(request: NextRequest) { +export const POST = withRouteProtection(async function POST(request: NextRequest) { try { - const body = await request.json(); + const body = await request.json().catch(() => null); + const fullName = typeof body?.fullName === "string" ? body.fullName.trim() : ""; - if (!body.fullName || typeof body.fullName !== "string") { + if (!fullName || !isRepoFullName(fullName)) { return NextResponse.json({ error: "Missing required field: fullName" }, { status: 400 }); } const repo = await addMonitoredRepo({ - githubId: body.githubId ?? 0, - fullName: body.fullName, - htmlUrl: body.htmlUrl ?? "", - isPrivate: body.isPrivate ?? false, - defaultBranch: body.defaultBranch ?? "main", + githubId: typeof body?.githubId === "number" ? body.githubId : 0, + fullName, + htmlUrl: typeof body?.htmlUrl === "string" ? body.htmlUrl : "", + isPrivate: body?.isPrivate === true, + defaultBranch: typeof body?.defaultBranch === "string" && body.defaultBranch.trim() ? body.defaultBranch : "main", }); return NextResponse.json(repo, { status: 201 }); @@ -36,9 +45,13 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : "Failed to add monitored repo"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}, { + route: "/api/github/monitored", + errorMessage: "Failed to add monitored repository", + rateLimit: API_RATE_LIMITS.githubWrites, +}); -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest) { try { const { searchParams } = new URL(request.url); const repoId = searchParams.get("id"); @@ -58,4 +71,8 @@ export async function DELETE(request: NextRequest) { const message = error instanceof Error ? error.message : "Failed to remove monitored repo"; return NextResponse.json({ error: message }, { status: 500 }); } -} +}, { + route: "/api/github/monitored", + errorMessage: "Failed to remove monitored repository", + rateLimit: API_RATE_LIMITS.githubWrites, +}); diff --git a/src/app/api/github/repos/route.ts b/src/app/api/github/repos/route.ts index 89b8f87..e98a751 100644 --- a/src/app/api/github/repos/route.ts +++ b/src/app/api/github/repos/route.ts @@ -1,7 +1,9 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { fetchGitHubRepos, isGitHubTokenConfigured, fetchTokenScopes } from "@/lib/github"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function GET() { +export const GET = withRouteProtection(async function GET(_request: NextRequest) { + void _request; if (!isGitHubTokenConfigured()) { return NextResponse.json( { error: "GITHUB_TOKEN is not configured. Add it to your .env file." }, @@ -20,4 +22,8 @@ export async function GET() { const message = error instanceof Error ? error.message : "Failed to fetch GitHub repos"; return NextResponse.json({ error: message }, { status: 502 }); } -} +}, { + route: "/api/github/repos", + errorMessage: "Failed to fetch GitHub repositories", + rateLimit: API_RATE_LIMITS.githubReads, +}); diff --git a/src/app/api/github/scan/route.ts b/src/app/api/github/scan/route.ts index 565f157..48474d0 100644 --- a/src/app/api/github/scan/route.ts +++ b/src/app/api/github/scan/route.ts @@ -4,8 +4,11 @@ import { parseDependencyFiles } from "@/lib/dependency-parser"; import { queryOSVBatch } from "@/lib/osv"; import { updateLastScan } from "@/lib/monitored-repos-store"; import { DependencyScanResult } from "@/lib/github-types"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; -export async function POST(request: NextRequest) { +const isRepoFullName = (value: string): boolean => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { if (!isGitHubTokenConfigured()) { return NextResponse.json( { error: "GITHUB_TOKEN is not configured" }, @@ -14,11 +17,11 @@ export async function POST(request: NextRequest) { } try { - const body = await request.json(); - const fullName = body.fullName; - const branch = body.branch; + const body = await request.json().catch(() => null); + const fullName = typeof body?.fullName === "string" ? body.fullName.trim() : ""; + const branch = typeof body?.branch === "string" && body.branch.trim() ? body.branch : undefined; - if (!fullName || typeof fullName !== "string") { + if (!fullName || !isRepoFullName(fullName)) { return NextResponse.json( { error: "Missing required field: fullName" }, { status: 400 } @@ -56,6 +59,10 @@ export async function POST(request: NextRequest) { return NextResponse.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Scan failed"; - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: message }, { status: 502 }); } -} +}, { + route: "/api/github/scan", + errorMessage: "Failed to scan GitHub repository", + rateLimit: API_RATE_LIMITS.githubScans, +}); diff --git a/src/components/ReposPageClient.tsx b/src/components/ReposPageClient.tsx index 876d201..7620700 100644 --- a/src/components/ReposPageClient.tsx +++ b/src/components/ReposPageClient.tsx @@ -530,7 +530,7 @@ const ScanResults = ({ result, repoFullName }: ScanResultsProps) => {
    {displayedVulns.map((match) => ( setFixingMatch(match)} /> @@ -636,6 +636,11 @@ const VulnerabilityRow = ({ match, onFix }: VulnerabilityRowProps) => { dev dependency )} + {match.matchedDependency.manifestPath && ( + + {match.matchedDependency.manifestPath} + + )}
    diff --git a/src/components/VulnerabilityFixModal.tsx b/src/components/VulnerabilityFixModal.tsx index 236e6df..dfdf586 100644 --- a/src/components/VulnerabilityFixModal.tsx +++ b/src/components/VulnerabilityFixModal.tsx @@ -98,6 +98,11 @@ export default function VulnerabilityFixModal({ {match.matchedDependency.name} @{match.matchedDependency.version}

    + {match.matchedDependency.manifestPath && ( +

    + {match.matchedDependency.manifestPath} +

    + )}
    + + Back to Search + + + {feedback && ( +
    + {feedback.message} +
    + )} + {projects.length === 0 && !loading ? ( -
    - No projects yet. Add a CVE to a project from search results or the detail page. +
    +

    No projects yet

    +

    Create a project here or add a CVE to one from search results or the detail view.

    +
    + ) : loading ? ( +
    + {Array.from({ length: 3 }).map((_, index) => ( +
    +
    +
    +
    +
    +
    +
    +
    + ))}
    ) : (
    @@ -99,8 +177,9 @@ export default function ProjectsPageClient() {
    @@ -112,8 +191,9 @@ export default function ProjectsPageClient() { @@ -137,7 +217,13 @@ export default function ProjectsPageClient() {
    )} - +
))} diff --git a/src/components/WatchlistPageClient.tsx b/src/components/WatchlistPageClient.tsx index d499cd3..f4c3357 100644 --- a/src/components/WatchlistPageClient.tsx +++ b/src/components/WatchlistPageClient.tsx @@ -1,31 +1,51 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { getCVEById } from "@/lib/api"; import { CVEDetail, CVESummary } from "@/lib/types"; -import { loadWatchlist, WATCHLIST_UPDATED_EVENT } from "@/lib/watchlist"; -import { loadTriageMap, readTriageMap, TRIAGE_UPDATED_EVENT, TriageStatus } from "@/lib/triage"; +import { loadWatchlist, removeWatchlistItems, WATCHLIST_UPDATED_EVENT } from "@/lib/watchlist"; +import { addProjectItemAPI, createProjectAPI, listProjectsAPI } from "@/lib/projects-api"; +import { + loadTriageMap, + loadTriageRecord, + readTriageMap, + TRIAGE_UPDATED_EVENT, + TriageStatus, + writeTriageRecord, +} from "@/lib/triage"; +import { ProjectRecord } from "@/lib/types"; import CVEList from "@/components/CVEList"; import AIDigestPanel from "@/components/AIDigestPanel"; export default function WatchlistPageClient() { const [items, setItems] = useState([]); + const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState<"all" | TriageStatus>("all"); + const [selectedIds, setSelectedIds] = useState([]); + const [bulkStatus, setBulkStatus] = useState("investigating"); + const [bulkProjectId, setBulkProjectId] = useState(""); + const [newProjectName, setNewProjectName] = useState(""); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + const [actionBusy, setActionBusy] = useState(null); - useEffect(() => { - let cancelled = false; - - async function load() { + const load = useCallback(async () => { + try { setLoading(true); - const [ids] = await Promise.all([loadWatchlist(), loadTriageMap()]); + const [ids, _triageMap, nextProjects] = await Promise.all([ + loadWatchlist(), + loadTriageMap(), + listProjectsAPI().catch(() => []), + ]); + + void _triageMap; if (ids.length === 0) { - if (!cancelled) { - setItems([]); - setLoading(false); - } + setItems([]); + setProjects(nextProjects); + setLoading(false); return; } @@ -39,22 +59,29 @@ export default function WatchlistPageClient() { }) ); - if (!cancelled) { - setItems(results.filter((item): item is CVEDetail => Boolean(item))); - setLoading(false); - } + setItems(results.filter((item): item is CVEDetail => Boolean(item))); + setProjects(nextProjects); + setLoading(false); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to load watchlist workspace data"); + setLoading(false); } + }, []); - load(); + useEffect(() => { + void load(); window.addEventListener(WATCHLIST_UPDATED_EVENT, load); window.addEventListener(TRIAGE_UPDATED_EVENT, load); return () => { - cancelled = true; window.removeEventListener(WATCHLIST_UPDATED_EVENT, load); window.removeEventListener(TRIAGE_UPDATED_EVENT, load); }; - }, []); + }, [load]); + + useEffect(() => { + setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id))); + }, [items]); const filteredItems = useMemo(() => { if (statusFilter === "all") return items; @@ -81,6 +108,95 @@ export default function WatchlistPageClient() { return counts; }, [items]); + const visibleIds = filteredItems.map((item) => item.id); + const selectedVisibleCount = selectedIds.filter((id) => visibleIds.includes(id)).length; + + const setFeedback = (kind: "success" | "error", message: string) => { + if (kind === "success") { + setActionSuccess(message); + setActionError(null); + return; + } + + setActionError(message); + setActionSuccess(null); + }; + + const handleBulkRemove = async () => { + if (selectedIds.length === 0) return; + setActionBusy("remove"); + + try { + await removeWatchlistItems(selectedIds); + setSelectedIds([]); + setFeedback("success", `Removed ${selectedIds.length} ${selectedIds.length === 1 ? "item" : "items"} from the watchlist.`); + await load(); + } catch (error) { + setFeedback("error", error instanceof Error ? error.message : "Failed to remove selected watchlist items"); + } finally { + setActionBusy(null); + } + }; + + const handleBulkTriage = async () => { + if (selectedIds.length === 0) return; + setActionBusy("triage"); + + try { + await Promise.all( + selectedIds.map(async (cveId) => { + const current = await loadTriageRecord(cveId); + await writeTriageRecord({ + ...current, + status: bulkStatus, + updatedAt: new Date().toISOString(), + }); + }) + ); + setFeedback("success", `Updated triage status to ${bulkStatus} for ${selectedIds.length} ${selectedIds.length === 1 ? "item" : "items"}.`); + } catch (error) { + setFeedback("error", error instanceof Error ? error.message : "Failed to update triage status"); + } finally { + setActionBusy(null); + } + }; + + const handleBulkProjectAssignment = async () => { + if (selectedIds.length === 0) return; + if (!bulkProjectId) { + setFeedback("error", "Choose an existing project or create a new one first."); + return; + } + + setActionBusy("project"); + + try { + let projectId = bulkProjectId; + let projectName = projects.find((project) => project.id === bulkProjectId)?.name || "project"; + + if (bulkProjectId === "__new__") { + if (!newProjectName.trim()) { + setFeedback("error", "Enter a new project name before assigning selected CVEs."); + setActionBusy(null); + return; + } + + const project = await createProjectAPI({ name: newProjectName.trim() }); + projectId = project.id; + projectName = project.name; + setNewProjectName(""); + } + + await Promise.all(selectedIds.map((cveId) => addProjectItemAPI(projectId, { cveId }))); + setProjects(await listProjectsAPI()); + setFeedback("success", `Added ${selectedIds.length} ${selectedIds.length === 1 ? "item" : "items"} to ${projectName}.`); + } catch (error) { + setFeedback("error", error instanceof Error ? error.message : "Failed to assign selected CVEs to a project"); + } finally { + setActionBusy(null); + } + }; + return (
@@ -93,6 +209,12 @@ export default function WatchlistPageClient() {
+ {(actionSuccess || actionError) && ( +
+ {actionError || actionSuccess} +
+ )} +
setStatusFilter("all")} /> setStatusFilter("new")} /> @@ -122,11 +244,138 @@ export default function WatchlistPageClient() { />
+ {items.length > 0 && ( +
+
+
+

Bulk Actions

+

+ {selectedIds.length > 0 + ? `${selectedIds.length} selected across the current watchlist.` + : "Select CVEs to remove them from the watchlist, update triage, or assign them to a project."} +

+
+
+ + +
+
+ +
+
+

Watchlist

+

Remove selected CVEs from the current workspace watchlist.

+ +
+ +
+

Triage

+

Set a shared triage status for the selected CVEs.

+
+ + +
+
+ +
+

Project Assignment

+

Add the selected CVEs to an existing project or create a new one inline.

+
+ + +
+ {bulkProjectId === "__new__" && ( + setNewProjectName(event.target.value)} + placeholder="New project name" + className="mt-2 w-full rounded-lg border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-sm text-white placeholder-gray-600 outline-none" + /> + )} +
+
+ + {selectedVisibleCount > 0 && ( +

+ {selectedVisibleCount} of {visibleIds.length} visible items selected for the current filter. +

+ )} +
+ )} +
- + 0} + selectedIds={selectedIds} + onToggleSelect={(cveId) => { + setSelectedIds((current) => + current.includes(cveId) ? current.filter((id) => id !== cveId) : [...current, cveId] + ); + }} + emptyTitle={items.length === 0 ? "Your watchlist is empty" : "No watchlist items match this filter"} + emptyBody={items.length === 0 ? "Bookmark CVEs from search results or the detail page to start tracking them here." : "Try another triage status or update triage on the selected vulnerabilities."} + skeletonCount={6} + />
); } diff --git a/src/components/WorkspaceDataPanel.tsx b/src/components/WorkspaceDataPanel.tsx new file mode 100644 index 0000000..084cc0d --- /dev/null +++ b/src/components/WorkspaceDataPanel.tsx @@ -0,0 +1,155 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import { useRef, useState } from "react"; + +interface ImportResult { + success: boolean; + mode: "merge" | "replace"; + imported: { + watchlist: number; + savedViews: number; + alertRules: number; + triageRecords: number; + projects: number; + }; +} + +export default function WorkspaceDataPanel() { + const fileInputRef = useRef(null); + const [mode, setMode] = useState<"merge" | "replace">("merge"); + const [busy, setBusy] = useState(null); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const handleExport = async () => { + setBusy("export"); + setMessage(null); + + try { + const res = await fetch("/api/workspace/export", { cache: "no-store" }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error || "Failed to export workspace data"); + } + + const snapshot = await res.json(); + const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const date = typeof snapshot?.exportedAt === "string" ? snapshot.exportedAt.slice(0, 10) : new Date().toISOString().slice(0, 10); + anchor.href = url; + anchor.download = `cvesearch-workspace-${date}.json`; + anchor.click(); + URL.revokeObjectURL(url); + setMessage({ type: "success", text: "Workspace export downloaded." }); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Failed to export workspace data" }); + } finally { + setBusy(null); + } + }; + + const handleImport = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + setBusy("import"); + setMessage(null); + + try { + const text = await file.text(); + const snapshot = JSON.parse(text); + const res = await fetch("/api/workspace/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, snapshot }), + }); + const data = (await res.json().catch(() => null)) as ImportResult | { error?: string } | null; + + if (!res.ok) { + throw new Error((data as { error?: string } | null)?.error || "Failed to import workspace data"); + } + + const result = data as ImportResult; + setMessage({ + type: "success", + text: `Imported ${result.imported.watchlist} watchlist items, ${result.imported.savedViews} saved views, ${result.imported.alertRules} alert rules, ${result.imported.triageRecords} triage records, and ${result.imported.projects} projects using ${result.mode} mode.`, + }); + event.target.value = ""; + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Failed to import workspace data" }); + } finally { + setBusy(null); + } + }; + + return ( +
+
+

Workspace Data

+

+ Export or import projects, watchlist items, saved views, alert rules, and triage records for this workspace. +

+
+ +
+
+ Import Mode +
+ + +
+

+ `Merge` keeps current data and upserts imported records. `Replace` clears current workspace data first. +

+
+ +
+ + + void handleImport(event)} + className="hidden" + /> +
+
+ + {message && ( +
+ {message.text} +
+ )} +
+ ); +} diff --git a/src/lib/projects-store.ts b/src/lib/projects-store.ts index bf5cfb1..9914d42 100644 --- a/src/lib/projects-store.ts +++ b/src/lib/projects-store.ts @@ -1,5 +1,6 @@ import { AuditLogEntry, ProjectItem, ProjectRecord } from "./types"; import { getDb, withTransaction } from "./db"; +import { WorkspaceImportMode } from "./workspace-types"; const MAX_ACTIVITY_ENTRIES = 20; @@ -95,6 +96,69 @@ export function normalizeProjectName(name: string): string { return name.trim().replace(/\s+/g, " ").slice(0, 80); } +export async function importProjects(projects: ProjectRecord[], mode: WorkspaceImportMode): Promise { + withTransaction((db) => { + if (mode === "replace") { + db.prepare("DELETE FROM projects").run(); + } + + const upsertProject = db.prepare(` + INSERT INTO projects (id, name, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `); + const insertItem = db.prepare(` + INSERT INTO project_items (project_id, cve_id, note, added_at) + VALUES (?, ?, ?, ?) + `); + const insertActivity = db.prepare(` + INSERT INTO project_activity (id, project_id, action, summary, created_at) + VALUES (?, ?, ?, ?, ?) + `); + + for (const project of projects) { + const projectId = project.id || crypto.randomUUID(); + upsertProject.run( + projectId, + normalizeProjectName(project.name) || "Imported project", + project.description ?? "", + project.createdAt || new Date().toISOString(), + project.updatedAt || new Date().toISOString() + ); + + db.prepare("DELETE FROM project_items WHERE project_id = ?").run(projectId); + db.prepare("DELETE FROM project_activity WHERE project_id = ?").run(projectId); + + for (const item of project.items) { + insertItem.run( + projectId, + item.cveId, + item.note ?? "", + item.addedAt || new Date().toISOString() + ); + } + + const activity = project.activity.length > 0 + ? project.activity.slice(0, MAX_ACTIVITY_ENTRIES) + : [createActivityEntry("project_imported", `Imported project ${project.name}`, project.updatedAt || new Date().toISOString())]; + + for (const entry of activity) { + insertActivity.run( + entry.id || crypto.randomUUID(), + projectId, + entry.action, + entry.summary, + entry.createdAt || new Date().toISOString() + ); + } + } + }); +} + interface ProjectRow { id: string; name: string; diff --git a/src/lib/watchlist.ts b/src/lib/watchlist.ts index 1d12dcb..ec7960b 100644 --- a/src/lib/watchlist.ts +++ b/src/lib/watchlist.ts @@ -19,6 +19,24 @@ export async function toggleWatchlistItem(id: string): Promise { return next; } +export async function removeWatchlistItems(ids: string[]): Promise { + const res = await fetch("/api/watchlist", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + + if (!res.ok) { + throw new Error("Failed to remove watchlist items"); + } + + const data = await res.json().catch(() => []); + const next = Array.isArray(data) ? data.filter((value): value is string => typeof value === "string") : []; + watchlistCache = next; + dispatchWatchlistUpdated(); + return next; +} + export function isWatchlisted(id: string): boolean { return watchlistCache.includes(id); } diff --git a/src/lib/workspace-store.ts b/src/lib/workspace-store.ts index 2913f54..a0d5d4f 100644 --- a/src/lib/workspace-store.ts +++ b/src/lib/workspace-store.ts @@ -6,7 +6,7 @@ import { normalizeTriageRecord, TriageRecord, } from "./triage-shared"; -import { AlertRule, SavedView } from "./workspace-types"; +import { AlertRule, SavedView, WorkspaceImportMode } from "./workspace-types"; export async function listWatchlist(userId: string): Promise { const rows = getDb().prepare(` @@ -41,6 +41,26 @@ export async function toggleWatchlistEntry(userId: string, cveId: string): Promi return listWatchlist(userId); } +export async function removeWatchlistEntries(userId: string, cveIds: string[]): Promise { + if (cveIds.length === 0) { + return listWatchlist(userId); + } + + const uniqueIds = Array.from(new Set(cveIds.map((id) => id.trim()).filter(Boolean))); + if (uniqueIds.length === 0) { + return listWatchlist(userId); + } + + withTransaction((db) => { + const deleteStatement = db.prepare("DELETE FROM user_watchlist WHERE user_id = ? AND cve_id = ?"); + for (const cveId of uniqueIds) { + deleteStatement.run(userId, cveId); + } + }); + + return listWatchlist(userId); +} + export async function listSavedViewsForUser(userId: string): Promise { const rows = getDb().prepare(` SELECT id, name, search_json as searchJson, created_at as createdAt @@ -231,6 +251,83 @@ export async function writeTriageRecordForUser(userId: string, record: TriageRec }); } +export async function importWorkspaceStateForUser( + userId: string, + input: { + watchlist: string[]; + savedViews: SavedView[]; + alertRules: AlertRule[]; + triageRecords: TriageRecord[]; + }, + mode: WorkspaceImportMode +): Promise { + withTransaction((db) => { + if (mode === "replace") { + db.prepare("DELETE FROM user_watchlist WHERE user_id = ?").run(userId); + db.prepare("DELETE FROM user_saved_views WHERE user_id = ?").run(userId); + db.prepare("DELETE FROM user_alert_rules WHERE user_id = ?").run(userId); + db.prepare("DELETE FROM user_triage_records WHERE user_id = ?").run(userId); + } + + const insertWatchlist = db.prepare(` + INSERT OR REPLACE INTO user_watchlist (user_id, cve_id, added_at) + VALUES (?, ?, ?) + `); + const insertSavedView = db.prepare(` + INSERT OR REPLACE INTO user_saved_views (id, user_id, name, search_json, created_at) + VALUES (?, ?, ?, ?, ?) + `); + const insertAlertRule = db.prepare(` + INSERT OR REPLACE INTO user_alert_rules (id, user_id, name, search_json, created_at, last_checked_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + const insertTriage = db.prepare(` + INSERT OR REPLACE INTO user_triage_records ( + user_id, cve_id, status, owner, notes, tags_json, updated_at, activity_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const cveId of Array.from(new Set(input.watchlist.map((id) => id.trim()).filter(Boolean)))) { + insertWatchlist.run(userId, cveId, new Date().toISOString()); + } + + for (const view of input.savedViews) { + insertSavedView.run( + view.id || crypto.randomUUID(), + userId, + view.name.trim() || "Imported view", + JSON.stringify(normalizeSearchState(view.search)), + view.createdAt || new Date().toISOString() + ); + } + + for (const rule of input.alertRules) { + insertAlertRule.run( + rule.id || crypto.randomUUID(), + userId, + rule.name.trim() || "Imported alert", + JSON.stringify(normalizeSearchState(rule.search)), + rule.createdAt || new Date().toISOString(), + rule.lastCheckedAt ?? null + ); + } + + for (const triageRecord of input.triageRecords) { + const normalized = normalizeTriageRecord(triageRecord); + insertTriage.run( + userId, + normalized.cveId, + normalized.status, + normalized.owner, + normalized.notes, + JSON.stringify(normalized.tags), + normalized.updatedAt, + JSON.stringify(normalized.activity) + ); + } + }); +} + function parseSearchState(raw: string): SearchState | null { try { return normalizeSearchState(JSON.parse(raw)); diff --git a/src/lib/workspace-types.ts b/src/lib/workspace-types.ts index 868b041..ffedf84 100644 --- a/src/lib/workspace-types.ts +++ b/src/lib/workspace-types.ts @@ -1,4 +1,6 @@ import { SearchState } from "./search"; +import { TriageRecord } from "./triage-shared"; +import { ProjectRecord } from "./types"; export interface SavedView { id: string; @@ -14,3 +16,15 @@ export interface AlertRule { createdAt: string; lastCheckedAt: string | null; } + +export interface WorkspaceExportSnapshot { + version: 1; + exportedAt: string; + watchlist: string[]; + savedViews: SavedView[]; + alertRules: AlertRule[]; + triageRecords: TriageRecord[]; + projects: ProjectRecord[]; +} + +export type WorkspaceImportMode = "merge" | "replace"; diff --git a/tests/workspace-store.test.ts b/tests/workspace-store.test.ts index a6cea1f..aa6c688 100644 --- a/tests/workspace-store.test.ts +++ b/tests/workspace-store.test.ts @@ -7,6 +7,7 @@ import { getOrCreateWorkspaceSession } from "../src/lib/auth-session"; import { createAlertRuleForUser, createSavedViewForUser, + importWorkspaceStateForUser, listAlertRulesForUser, listSavedViewsForUser, listWatchlist, @@ -15,6 +16,7 @@ import { writeTriageRecordForUser, } from "../src/lib/workspace-store"; import { createDefaultTriageRecord } from "../src/lib/triage-shared"; +import { importProjects, listProjects } from "../src/lib/projects-store"; test("workspace stores are isolated per session user", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cvesearch-workspace-")); @@ -75,3 +77,60 @@ test("workspace stores are isolated per session user", async () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + +test("workspace import supports replace mode for user data and projects", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cvesearch-workspace-import-")); + const previousDatabaseFile = process.env.DATABASE_FILE; + process.env.DATABASE_FILE = path.join(tempDir, "workspace.db"); + + try { + const session = getOrCreateWorkspaceSession(new Request("https://example.test/api/workspace/import")); + + await toggleWatchlistEntry(session.userId, "CVE-2026-0001"); + + await importWorkspaceStateForUser(session.userId, { + watchlist: ["CVE-2026-1234"], + savedViews: [{ + id: "view-1", + name: "Critical", + search: { query: "openssl", vendor: "", product: "", cwe: "", since: "", minSeverity: "CRITICAL", sort: "risk_desc", page: 1, perPage: 20 }, + createdAt: new Date().toISOString(), + }], + alertRules: [{ + id: "alert-1", + name: "Critical Alert", + search: { query: "openssl", vendor: "", product: "", cwe: "", since: "", minSeverity: "HIGH", sort: "risk_desc", page: 1, perPage: 20 }, + createdAt: new Date().toISOString(), + lastCheckedAt: null, + }], + triageRecords: [{ + ...createDefaultTriageRecord("CVE-2026-1234"), + status: "mitigated", + updatedAt: new Date().toISOString(), + }], + }, "replace"); + + await importProjects([{ + id: "project-1", + name: "Imported Project", + description: "Imported", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + items: [{ cveId: "CVE-2026-1234", note: "Track fix", addedAt: new Date().toISOString() }], + activity: [], + }], "replace"); + + assert.deepEqual(await listWatchlist(session.userId), ["CVE-2026-1234"]); + assert.equal((await listSavedViewsForUser(session.userId))[0]?.name, "Critical"); + assert.equal((await listAlertRulesForUser(session.userId))[0]?.name, "Critical Alert"); + assert.equal((await readTriageRecordForUser(session.userId, "CVE-2026-1234")).status, "mitigated"); + assert.equal((await listProjects())[0]?.name, "Imported Project"); + } finally { + if (previousDatabaseFile === undefined) { + delete process.env.DATABASE_FILE; + } else { + process.env.DATABASE_FILE = previousDatabaseFile; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); From e1b00e705212b28dcfaa648e0c9be7f2c5a31ee2 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:05:45 +0100 Subject: [PATCH 13/30] feat(ui): adopt Radix theme and richer dashboards Add a shared Radix theme layer and move key settings and dashboard surfaces onto consistent Radix primitives. Expand the home dashboard with analyst, maintainer, and incident response views so the landing page better matches operational workflows. --- docs/todo.md | 8 +- package-lock.json | 1974 ++++++++++++++++++++++- package.json | 1 + src/app/globals.css | 13 + src/app/layout.tsx | 16 +- src/components/AISettingsPageClient.tsx | 203 +-- src/components/AppThemeProvider.tsx | 18 + src/components/DashboardPanel.tsx | 152 +- src/components/WorkspaceDataPanel.tsx | 87 +- src/lib/server-api.ts | 67 + src/lib/types.ts | 16 + 11 files changed, 2282 insertions(+), 273 deletions(-) create mode 100644 src/components/AppThemeProvider.tsx diff --git a/docs/todo.md b/docs/todo.md index efc3be4..228f76e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -49,8 +49,8 @@ ### Build Later -- integrate Radix UI primitives/theme for a more consistent UI system -- add richer dashboard views for analysts, maintainers, and incident response workflows +- [x] integrate Radix UI primitives/theme for a more consistent UI system +- [x] add richer dashboard views for analysts, maintainers, and incident response workflows - add better empty states, skeleton states, and success/error feedback across the app - [x] add import/export for projects, triage state, saved views, and watchlists - expand project management with owners, due dates, labels, status, and timeline views @@ -82,8 +82,8 @@ ## Product and UX -- integrate Radix UI primitives/theme for a more consistent UI system -- add richer dashboard views for analysts, maintainers, and incident response workflows +- [x] integrate Radix UI primitives/theme for a more consistent UI system +- [x] add richer dashboard views for analysts, maintainers, and incident response workflows - [x] improve result cards with stronger severity, EPSS, KEV, and recency cues - [x] add better empty states, skeleton states, and success/error feedback across the app - [x] add bulk actions for watchlist, triage, and project assignment diff --git a/package-lock.json b/package-lock.json index b973b8b..7a7818a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@radix-ui/themes": "^3.3.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", @@ -455,6 +456,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1116,116 +1155,1646 @@ "node": ">= 10" } }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "license": "MIT" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/rect": "1.1.1" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/react-primitive": "2.1.3" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@radix-ui/themes": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz", + "integrity": "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==", "license": "MIT", - "engines": { - "node": ">=12.4.0" + "dependencies": { + "@radix-ui/colors": "^3.0.0", + "classnames": "^2.3.2", + "radix-ui": "^1.1.3", + "react-remove-scroll-bar": "^2.3.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@rtsao/scc": { @@ -1561,7 +3130,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1571,7 +3140,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2204,6 +3773,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2601,6 +4182,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2660,7 +4247,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2795,6 +4382,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3708,6 +5301,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -5434,6 +7036,83 @@ ], "license": "MIT" }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -5462,6 +7141,75 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6432,6 +8180,58 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index da4b137..6042c49 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "npm run test:compile && node --test .test-dist/tests/*.test.js" }, "dependencies": { + "@radix-ui/themes": "^3.3.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/src/app/globals.css b/src/app/globals.css index 12c98d8..d4d4e42 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,6 +12,19 @@ body { color: var(--color-foreground); } +.rt-Theme { + min-height: 100vh; + background: transparent; + color: var(--color-foreground); +} + +.rt-Theme button, +.rt-Theme input, +.rt-Theme select, +.rt-Theme textarea { + font: inherit; +} + .app-shell { width: 95vw; margin-inline: auto; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6670ade..8a55c5c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import "@radix-ui/themes/styles.css"; +import AppThemeProvider from "@/components/AppThemeProvider"; import Header from "@/components/Header"; import "./globals.css"; @@ -15,12 +17,14 @@ export default function RootLayout({ return ( -
-
- {/* Subtle grid background */} -
-
{children}
-
+ +
+
+ {/* Subtle grid background */} +
+
{children}
+
+ ); diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 3fcdeb3..b084869 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -1,4 +1,14 @@ import Link from "next/link"; +import { + Badge, + Button, + Callout, + Card, + Flex, + Grid, + Heading, + Text, +} from "@radix-ui/themes"; import { ServerAIConfigurationSummary } from "@/lib/ai-service"; import { AIRunRecord } from "@/lib/types"; import WorkspaceDataPanel from "@/components/WorkspaceDataPanel"; @@ -6,139 +16,146 @@ import WorkspaceDataPanel from "@/components/WorkspaceDataPanel"; export default function AISettingsPageClient({ summary, recentRuns }: { summary: ServerAIConfigurationSummary; recentRuns: AIRunRecord[] }) { return (
-
+
-

AI Settings

-

AI features now use server-side configuration so provider credentials never need to live in the browser.

+ AI Settings + + AI features now use server-side configuration so provider credentials never need to live in the browser. +
- - Back to Search - -
+ +
-
-
- Provider -

{summary.provider}

-
-
- Mode -

{summary.mode === "configured" ? "Configured provider" : "Heuristic fallback"}

-
-
- Model -

{summary.model || "Not required in heuristic mode"}

-
-
+ + + + + -
- Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. -
+ + + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. + + -
- {summary.configured - ? `Server-side AI is active using ${summary.provider}${summary.model ? ` (${summary.model})` : ""}.` - : "No server-side AI provider key is configured, so the app is using deterministic heuristic fallbacks."} -
+ + + {summary.configured + ? `Server-side AI is active using ${summary.provider}${summary.model ? ` (${summary.model})` : ""}.` + : "No server-side AI provider key is configured, so the app is using deterministic heuristic fallbacks."} + + -
- + + {summary.availableProviders.length > 0 ? `Available providers: ${summary.availableProviders.join(", ")}` : "No model provider credentials detected on the server."} - -
+ + + {summary.availableProviders.map((provider) => ( + {provider} + ))} + + -
-
-

Per-Feature Configuration

-

Each AI flow can inherit the global server configuration or override it with feature-specific provider and model settings.

-
+ + Per-Feature Configuration + + Each AI flow can inherit the global server configuration or override it with feature-specific provider and model settings. + -
+ {summary.featureConfigurations.map((featureConfig) => ( -
-
-

{featureConfig.feature}

- {featureConfig.mode === "configured" ? "Configured" : "Heuristic"} + + + {featureConfig.feature} + + {featureConfig.mode === "configured" ? "Configured" : "Heuristic"} + + +
+ Provider: {featureConfig.provider} + Model: {featureConfig.model || "Not required in heuristic mode"}
-
-

- Provider: {featureConfig.provider} -

-

- Model: {featureConfig.model || "Not required in heuristic mode"} -

-
-
+ ))} -
-
+ +
-
- -
+ -
-
-
-

Recent AI Runs

-

Read-only history of recent prompts, outcomes, tool traces, and failures.

-
-
+ + Recent AI Runs + + Read-only history of recent prompts, outcomes, tool traces, and failures. + {recentRuns.length > 0 ? ( -
+
{recentRuns.map((run) => ( -
-
- {run.feature} - {run.status} - {run.provider}{run.model ? ` • ${run.model}` : ""} - {new Date(run.createdAt).toLocaleString("en-US")} - {run.durationMs}ms -
+ + + {run.feature} + {run.status} + {run.provider}{run.model ? ` • ${run.model}` : ""} + {new Date(run.createdAt).toLocaleString("en-US")} + {run.durationMs}ms +
-
-

Prompt

-
{run.prompt}
-
- -
-

Output

-
{run.output}
-
+ + {run.toolCalls.length > 0 ? (
-

Tool Calls

-
    + Tool Calls +
    {run.toolCalls.map((call) => ( -
  • +
    {call.tool} — {call.summary} -
  • +
    ))} -
+
) : null} {run.error ? ( -
- {run.error} -
+ + {run.error} + ) : null}
-
+ ))}
) : ( -

No AI runs have been recorded yet.

+ No AI runs have been recorded yet. )} -
+
); } + +function MetricCard({ label, value, className = "" }: { label: string; value: string; className?: string }) { + return ( + + {label} + {value} + + ); +} + +function RunBlock({ title, value }: { title: string; value: string }) { + return ( +
+ {title} +
{value}
+
+ ); +} diff --git a/src/components/AppThemeProvider.tsx b/src/components/AppThemeProvider.tsx new file mode 100644 index 0000000..e570f19 --- /dev/null +++ b/src/components/AppThemeProvider.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Theme } from "@radix-ui/themes"; + +export default function AppThemeProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/DashboardPanel.tsx b/src/components/DashboardPanel.tsx index c6f67b5..8561249 100644 --- a/src/components/DashboardPanel.tsx +++ b/src/components/DashboardPanel.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { HomeDashboardData } from "@/lib/types"; +import { Badge, Button, Card, Flex, Grid, Heading, Tabs, Text } from "@radix-ui/themes"; +import { DashboardWorkflowView, HomeDashboardData } from "@/lib/types"; import CVECard from "./CVECard"; interface DashboardPanelProps { @@ -11,27 +12,58 @@ interface DashboardPanelProps { export default function DashboardPanel({ dashboard }: DashboardPanelProps) { return (
-
- - - - -
+ + +
+ Operations Overview + Richer views for analysts, maintainers, and incident response + + The dashboard now breaks the recent sample into role-based queues so each team can jump directly into the issues that matter most to their workflow. + +
+ + + + + + + +
+
-
+ {dashboard.presets.map((preset) => ( - -
{preset.title}
-

{preset.description}

+ + + {preset.title} + {preset.description} + ))} -
+ + + + Workflow Views + + Switch between focused queues for analyst triage, maintainer patch planning, and incident response. + -
+ + + {dashboard.workflowViews.map((view) => ( + {view.title} + ))} + + + {dashboard.workflowViews.map((view) => ( + + + + ))} + + + + -
+
); } function SummaryTile({ label, value }: { label: string; value: number }) { return ( -
-
{value}
-
{label}
+ + {value} + {label} + + ); +} + +function WorkflowPanel({ view }: { view: DashboardWorkflowView }) { + return ( +
+ +
+ {view.title} + {view.description} +
+ +
+ + + {view.metrics.map((metric) => ( + + {metric.value} + {metric.label} + + ))} + + +
+ {view.cves.length === 0 ? ( + + No matching vulnerabilities in the current sample. + + ) : ( + view.cves.map((cve) => ) + )} +
); } @@ -71,20 +138,43 @@ function DashboardColumn({ cves: HomeDashboardData["latestCritical"]; }) { return ( -
-
-

{title}

-

{description}

-
-
+ + {title} + {description} +
{cves.length === 0 ? ( -
- No matching vulnerabilities in the current sample. -
+ + No matching vulnerabilities in the current sample. + ) : ( cves.map((cve) => ) )}
-
+ + ); +} + +function CompactDashboardCard({ cve }: { cve: HomeDashboardData["latestCritical"][number] }) { + return ( + + +
+ + {cve.id} + + + {cve.summary || cve.description || "No description available."} + +
+ {typeof cve.cvss3 === "number" || typeof cve.cvss === "number" ? ( + CVSS {(cve.cvss3 ?? cve.cvss)?.toFixed(1)} + ) : null} +
+ + {cve.kev ? KEV : null} + {typeof cve.epss === "number" ? EPSS {(cve.epss * 100).toFixed(0)}% : null} + {(cve.vulnerable_product?.length ?? 0) > 0 ? {cve.vulnerable_product?.length} products : null} + +
); } diff --git a/src/components/WorkspaceDataPanel.tsx b/src/components/WorkspaceDataPanel.tsx index 084cc0d..e2487cb 100644 --- a/src/components/WorkspaceDataPanel.tsx +++ b/src/components/WorkspaceDataPanel.tsx @@ -2,6 +2,7 @@ import type { ChangeEvent } from "react"; import { useRef, useState } from "react"; +import { Badge, Button, Callout, Card, Flex, Heading, Text } from "@radix-ui/themes"; interface ImportResult { success: boolean; @@ -86,55 +87,22 @@ export default function WorkspaceDataPanel() { }; return ( -
-
-

Workspace Data

-

- Export or import projects, watchlist items, saved views, alert rules, and triage records for this workspace. -

-
- -
+ +
- Import Mode -
- - -
-

- `Merge` keeps current data and upserts imported records. `Replace` clears current workspace data first. -

+ Workspace Data + + Export or import projects, watchlist items, saved views, alert rules, and triage records for this workspace. +
- -
- - + + void handleImport(event)} className="hidden" /> -
-
+ + + + + + + + + + `Merge` keeps current data and upserts imported records. `Replace` clears current workspace data first. + - {message && ( -
- {message.text} + {message ? ( +
+ + {message.text} +
- )} -
+ ) : null} + ); } diff --git a/src/lib/server-api.ts b/src/lib/server-api.ts index 80356e3..477d42e 100644 --- a/src/lib/server-api.ts +++ b/src/lib/server-api.ts @@ -4,7 +4,9 @@ import { applySearchResultPreferences, buildPresetHref, getSearchValidationError, + getExploitReferenceCount, hasActiveFilters, + hasExploitSignals, isDirectVulnerabilityIdQuery, matchesSearchState, wasPublishedWithinDays, @@ -185,6 +187,30 @@ export async function getHomeDashboardData(state: SearchState): Promise Boolean(cve.kev) || (cve.epss ?? 0) >= 0.2 || wasPublishedWithinDays(cve, 14)), + { + ...state, + minSeverity: "HIGH", + sort: "risk_desc", + } + ).slice(0, 4); + const maintainerPatchRadar = applySearchResultPreferences( + latest.filter((cve) => (cve.vulnerable_product?.length ?? 0) > 0), + { + ...state, + minSeverity: "HIGH", + sort: "published_desc", + } + ).slice(0, 4); + const incidentResponse = applySearchResultPreferences( + latest.filter((cve) => Boolean(cve.kev) || hasExploitSignals(cve)), + { + ...state, + minSeverity: "HIGH", + sort: "risk_desc", + } + ).slice(0, 4); return { summary: { @@ -225,6 +251,47 @@ export async function getHomeDashboardData(state: SearchState): Promise Boolean(cve.kev)).length) }, + { label: "Fresh this week", value: String(analystQueue.filter((cve) => wasPublishedWithinDays(cve, 7)).length) }, + ], + cves: analystQueue, + }, + { + id: "maintainer", + title: "Maintainer Patch Radar", + description: "Focus on recently published package issues with product exposure and enough detail to plan remediation work.", + accentClassName: "border-emerald-500/25 bg-emerald-500/10 text-emerald-100", + href: buildPresetHref({ minSeverity: "HIGH", sort: "published_desc" }), + metrics: [ + { label: "Patch candidates", value: String(maintainerPatchRadar.length) }, + { label: "Product mentions", value: String(maintainerPatchRadar.reduce((sum, cve) => sum + Math.min(cve.vulnerable_product?.length ?? 0, 6), 0)) }, + { label: "Critical now", value: String(maintainerPatchRadar.filter((cve) => (cve.cvss3 ?? cve.cvss ?? 0) >= 9).length) }, + ], + cves: maintainerPatchRadar, + }, + { + id: "incident_response", + title: "Incident Response Signals", + description: "Pull the vulnerabilities with the strongest exploitation evidence into a faster response loop.", + accentClassName: "border-red-500/25 bg-red-500/10 text-red-100", + href: buildPresetHref({ minSeverity: "HIGH", sort: "risk_desc" }), + metrics: [ + { label: "Exploit-linked", value: String(incidentResponse.filter((cve) => hasExploitSignals(cve)).length) }, + { label: "Known exploited", value: String(incidentResponse.filter((cve) => Boolean(cve.kev)).length) }, + { label: "Exploit refs", value: String(incidentResponse.reduce((sum, cve) => sum + getExploitReferenceCount(cve), 0)) }, + ], + cves: incidentResponse, + }, + ], }; } catch { return null; diff --git a/src/lib/types.ts b/src/lib/types.ts index edd44b4..cd45e2c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -176,12 +176,28 @@ export interface DashboardSummary { knownExploitedCount: number; } +export interface DashboardMetric { + label: string; + value: string; +} + +export interface DashboardWorkflowView { + id: "analyst" | "maintainer" | "incident_response"; + title: string; + description: string; + accentClassName: string; + href: string; + metrics: DashboardMetric[]; + cves: CVESummary[]; +} + export interface HomeDashboardData { summary: DashboardSummary; presets: DashboardPreset[]; latestCritical: CVESummary[]; highestRisk: CVESummary[]; recentHighImpact: CVESummary[]; + workflowViews: DashboardWorkflowView[]; } export interface ProjectItem { From fcf1c14969f623039547f0f96e8ec31d031d6ee1 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:26:25 +0100 Subject: [PATCH 14/30] feat(ai): add triage and remediation agents --- docs/ai-platform.md | 22 ++ docs/todo.md | 32 +-- src/app/api/ai/cve/[id]/route.ts | 18 +- src/app/api/ai/remediation/[id]/route.ts | 49 ++++ src/app/api/ai/triage/[id]/route.ts | 49 ++++ src/app/cve/[id]/page.tsx | 7 +- src/components/AICveInsightPanel.tsx | 8 +- src/components/AIRemediationPlanPanel.tsx | 136 ++++++++++ src/components/AISettingsPageClient.tsx | 47 +++- src/components/AITriageAssistantPanel.tsx | 192 ++++++++++++++ src/components/TriagePanel.tsx | 12 +- src/lib/ai-prompts.ts | 111 +++++++++ src/lib/ai-runs-store.ts | 2 +- src/lib/ai-service.ts | 290 +++++++++++++++++++--- src/lib/ai-tool-registry.ts | 67 +++++ src/lib/ai.ts | 6 + src/lib/types.ts | 25 +- tests/ai-platform.test.ts | 40 +++ tests/ai.test.ts | 109 ++++++++ tests/fixtures/ai-search-evals.json | 32 +++ 20 files changed, 1196 insertions(+), 58 deletions(-) create mode 100644 docs/ai-platform.md create mode 100644 src/app/api/ai/remediation/[id]/route.ts create mode 100644 src/app/api/ai/triage/[id]/route.ts create mode 100644 src/components/AIRemediationPlanPanel.tsx create mode 100644 src/components/AITriageAssistantPanel.tsx create mode 100644 src/lib/ai-prompts.ts create mode 100644 src/lib/ai-tool-registry.ts create mode 100644 tests/ai-platform.test.ts create mode 100644 tests/fixtures/ai-search-evals.json diff --git a/docs/ai-platform.md b/docs/ai-platform.md new file mode 100644 index 0000000..b65ef83 --- /dev/null +++ b/docs/ai-platform.md @@ -0,0 +1,22 @@ +# AI Platform Notes + +## Vercel AI SDK Evaluation + +Recommendation: do not adopt the Vercel AI SDK yet. + +Why: +- the current typed AI service already covers the app's immediate needs for provider routing, structured JSON generation, fallback handling, and run logging +- the largest remaining gaps are prompt/version management, reusable tool metadata, and regression coverage, which are provider-agnostic and are now implemented directly in the app +- introducing the SDK now would add another abstraction layer before the app has multiple real tool-executing agent loops in production + +When to revisit: +- when agent workflows start chaining multiple tool invocations in a single request +- when streaming partial tool/state updates becomes a product requirement +- when the project needs provider-specific structured outputs or tool-execution helpers beyond the current service layer + +## Current Direction + +- keep the existing typed AI service as the execution layer +- version prompts explicitly in code so behavior changes are reviewable +- define a small tool registry that future agents can share +- expand regression datasets before introducing a larger agent runtime dependency diff --git a/docs/todo.md b/docs/todo.md index 228f76e..338ed83 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -19,13 +19,13 @@ - [x] add regression tests for dependency parsing and GitHub scan edge cases - [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage - [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows -- evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js -- define a small tool registry for agent workflows -- add prompt and version management so changes to agent behavior are explicit and reversible -- add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality -- add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses +- [x] evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js +- [x] define a small tool registry for agent workflows +- [x] add prompt and version management so changes to agent behavior are explicit and reversible +- [x] add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality +- [x] add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses - [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches -- add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions +- [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions - add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action - [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied - improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata @@ -40,7 +40,7 @@ - [x] enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals - [x] improve result cards with stronger severity, EPSS, KEV, and recency cues - [x] add bulk actions for watchlist, triage, and project assignment -- add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability +- [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review - add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view @@ -51,7 +51,7 @@ - [x] integrate Radix UI primitives/theme for a more consistent UI system - [x] add richer dashboard views for analysts, maintainers, and incident response workflows -- add better empty states, skeleton states, and success/error feedback across the app +- [x] add better empty states, skeleton states, and success/error feedback across the app - [x] add import/export for projects, triage state, saved views, and watchlists - expand project management with owners, due dates, labels, status, and timeline views - add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions @@ -87,7 +87,7 @@ - [x] improve result cards with stronger severity, EPSS, KEV, and recency cues - [x] add better empty states, skeleton states, and success/error feedback across the app - [x] add bulk actions for watchlist, triage, and project assignment -- add import/export for projects, triage state, saved views, and watchlists +- [x] add import/export for projects, triage state, saved views, and watchlists ## Vulnerability Management @@ -100,8 +100,8 @@ ## AI and Agent Platform - [x] replace ad hoc AI calls with a typed AI service layer that supports structured outputs, tool calling, and multi-step workflows -- evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js -- define a small tool registry for agent workflows: +- [x] evaluate adopting the Vercel AI SDK for typed tool execution, structured JSON generation, and reusable agent loops in Next.js +- [x] define a small tool registry for agent workflows: - search CVEs - fetch CVE details - read watchlist state @@ -109,14 +109,14 @@ - read and update project records - read and update triage state - [x] persist AI runs, prompts, outputs, tool calls, and failures for debugging and review -- add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality +- [x] add evaluation datasets and regression tests for AI outputs so prompt or model changes do not silently degrade quality - [x] add per-feature model/provider configuration instead of one shared global setting for every AI flow ## AI Features to Add - [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches -- add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions -- add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability +- [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions +- [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review - add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action @@ -129,8 +129,8 @@ - [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage - add usage tracking, latency metrics, and cost visibility for each AI feature -- add prompt and version management so changes to agent behavior are explicit and reversible -- add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses +- [x] add prompt and version management so changes to agent behavior are explicit and reversible +- [x] add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses - add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default ## Search and Data Quality diff --git a/src/app/api/ai/cve/[id]/route.ts b/src/app/api/ai/cve/[id]/route.ts index fa8f691..b705c19 100644 --- a/src/app/api/ai/cve/[id]/route.ts +++ b/src/app/api/ai/cve/[id]/route.ts @@ -5,13 +5,23 @@ import { generateCveInsight } from "@/lib/ai-service"; import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; import { readTriageRecordForUser } from "@/lib/workspace-store"; +import { CVEDetail } from "@/lib/types"; export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const session = getOrCreateWorkspaceSession(request); const { id } = await context.params; const body = await request.json().catch(() => null); - const detail = await getCVEByIdServer(decodeURIComponent(id)); - const [epss, projects] = await Promise.all([getEPSSServer(detail.id), listProjects()]); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI insight" }, { status: 502 }), session); + } + + const [epss, projects] = await Promise.all([ + getEPSSServer(detail.id).catch(() => null), + listProjects().catch(() => []), + ]); const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); const triage = body?.triage && typeof body.triage === "object" ? body.triage @@ -32,3 +42,7 @@ export const POST = withRouteProtection(async function POST(request: NextRequest errorMessage: "Failed to generate AI CVE insight", rateLimit: API_RATE_LIMITS.aiWrite, }); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/api/ai/remediation/[id]/route.ts b/src/app/api/ai/remediation/[id]/route.ts new file mode 100644 index 0000000..376c90c --- /dev/null +++ b/src/app/api/ai/remediation/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateRemediationPlan } from "@/lib/ai-service"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; +import { CVEDetail } from "@/lib/types"; +import { readTriageRecordForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI remediation" }, { status: 502 }), session); + } + + const [epss, projects] = await Promise.all([ + getEPSSServer(detail.id).catch(() => null), + listProjects().catch(() => []), + ]); + const triage = body?.triage && typeof body.triage === "object" + ? body.triage + : await readTriageRecordForUser(session.userId, detail.id); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const remediation = await generateRemediationPlan({ + detail, + epss, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); + + return applyWorkspaceSession(NextResponse.json(remediation), session); +}, { + route: "/api/ai/remediation/[id]", + errorMessage: "Failed to generate AI remediation plan", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/api/ai/triage/[id]/route.ts b/src/app/api/ai/triage/[id]/route.ts new file mode 100644 index 0000000..6a5eeaa --- /dev/null +++ b/src/app/api/ai/triage/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateTriageSuggestion } from "@/lib/ai-service"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer, getEPSSServer } from "@/lib/server-api"; +import { CVEDetail } from "@/lib/types"; +import { readTriageRecordForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI triage" }, { status: 502 }), session); + } + + const [epss, projects] = await Promise.all([ + getEPSSServer(detail.id).catch(() => null), + listProjects().catch(() => []), + ]); + const triage = body?.triage && typeof body.triage === "object" + ? body.triage + : await readTriageRecordForUser(session.userId, detail.id); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const suggestion = await generateTriageSuggestion({ + detail, + epss, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + }); + + return applyWorkspaceSession(NextResponse.json(suggestion), session); +}, { + route: "/api/ai/triage/[id]", + errorMessage: "Failed to generate AI triage suggestion", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/cve/[id]/page.tsx b/src/app/cve/[id]/page.tsx index aee7166..aa20396 100644 --- a/src/app/cve/[id]/page.tsx +++ b/src/app/cve/[id]/page.tsx @@ -17,6 +17,7 @@ import CopyLinkButton from "@/components/CopyLinkButton"; import TriagePanel from "@/components/TriagePanel"; import ProjectPickerButton from "@/components/ProjectPickerButton"; import AICveInsightPanel from "@/components/AICveInsightPanel"; +import AIRemediationPlanPanel from "@/components/AIRemediationPlanPanel"; export default function CVEDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); @@ -167,9 +168,11 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string

{description}

- + - + + + {aliases.length > 0 && (
diff --git a/src/components/AICveInsightPanel.tsx b/src/components/AICveInsightPanel.tsx index cad90ce..c9ac471 100644 --- a/src/components/AICveInsightPanel.tsx +++ b/src/components/AICveInsightPanel.tsx @@ -1,10 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { AICveInsight } from "@/lib/types"; +import { AICveInsight, CVEDetail } from "@/lib/types"; import { loadTriageRecord, TRIAGE_UPDATED_EVENT } from "@/lib/triage"; -export default function AICveInsightPanel({ cveId }: { cveId: string }) { +export default function AICveInsightPanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { const [insight, setInsight] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -21,7 +21,7 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { const res = await fetch(`/api/ai/cve/${encodeURIComponent(cveId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ triage }), + body: JSON.stringify({ triage, detail }), }); const data = await res.json().catch(() => null); if (!res.ok) { @@ -48,7 +48,7 @@ export default function AICveInsightPanel({ cveId }: { cveId: string }) { cancelled = true; window.removeEventListener(TRIAGE_UPDATED_EVENT, load); }; - }, [cveId]); + }, [cveId, detail]); return (
diff --git a/src/components/AIRemediationPlanPanel.tsx b/src/components/AIRemediationPlanPanel.tsx new file mode 100644 index 0000000..a7b4263 --- /dev/null +++ b/src/components/AIRemediationPlanPanel.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AIRemediationPlan, CVEDetail } from "@/lib/types"; +import { loadTriageRecord, TRIAGE_UPDATED_EVENT } from "@/lib/triage"; + +export default function AIRemediationPlanPanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + + try { + const triage = await loadTriageRecord(cveId); + const res = await fetch(`/api/ai/remediation/${encodeURIComponent(cveId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ triage, detail }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI remediation plan"); + } + + if (!cancelled) { + setPlan(data); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load AI remediation plan"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); + return () => { + cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); + }; + }, [cveId, detail]); + + return ( +
+
+
+

AI Remediation Agent

+

Drafts rollout strategy, compensating controls, validation, and ownership guidance without changing state automatically.

+
+ {plan?.requiresHumanApproval ? ( + + Human approval required + + ) : null} +
+ + {loading ?

Drafting remediation plan...

: null} + {error ?

{error}

: null} + + {plan && !loading ? ( +
+
+

Summary

+

{plan.summary}

+
+ +
+
+ + +
+
+

Strategy

+

{plan.strategy}

+
+
+

Ownership

+

{plan.ownerRationale}

+
+
+ + + + + +
+

Project Context

+

{plan.projectContext.summary}

+ {plan.projectContext.projectNames.length > 0 ? ( +
+ {plan.projectContext.projectNames.map((project) => ( + + ))} +
+ ) : null} +
+
+ ) : null} +
+ ); +} + +function PlanList({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ); +} + +function Badge({ label, tone }: { label: string; tone: "amber" | "emerald" | "gray" }) { + const tones = { + amber: "border-amber-500/20 bg-amber-500/10 text-amber-200", + emerald: "border-emerald-500/20 bg-emerald-500/10 text-emerald-200", + gray: "border-white/[0.08] bg-white/[0.05] text-gray-300", + } as const; + + return {label}; +} diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index b084869..92fd978 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -37,7 +37,7 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: - Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. @@ -86,6 +86,51 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: + + + Prompt Versions + + Prompt changes are versioned explicitly so behavior updates are visible and reversible. + +
+ {summary.promptTemplates.map((template) => ( + + +
+ {template.feature} + {template.description} +
+ {template.version} +
+
+ ))} +
+
+ + + Tool Registry + + Shared tools define the read and write capabilities available to current and future agent workflows. + +
+ {summary.toolRegistry.map((tool) => ( + + +
+ {tool.name} + {tool.description} + + Features: {tool.features.join(", ")} + +
+ {tool.access} +
+
+ ))} +
+
+
+ diff --git a/src/components/AITriageAssistantPanel.tsx b/src/components/AITriageAssistantPanel.tsx new file mode 100644 index 0000000..9ec2c41 --- /dev/null +++ b/src/components/AITriageAssistantPanel.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AITriageSuggestion, CVEDetail } from "@/lib/types"; +import { TRIAGE_UPDATED_EVENT, TriageRecord } from "@/lib/triage"; + +export default function AITriageAssistantPanel({ + cveId, + detail, + record, + onApply, +}: { + cveId: string; + detail?: CVEDetail | null; + record: TriageRecord; + onApply: (updater: (current: TriageRecord) => TriageRecord) => void; +}) { + const [suggestion, setSuggestion] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const requestBody = useMemo( + () => JSON.stringify({ + triage: { + cveId: record.cveId, + status: record.status, + owner: record.owner, + notes: record.notes, + tags: record.tags, + updatedAt: record.updatedAt, + }, + detail, + }), + [detail, record.cveId, record.notes, record.owner, record.status, record.tags, record.updatedAt] + ); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/ai/triage/${encodeURIComponent(cveId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: requestBody, + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI triage guidance"); + } + + if (!cancelled) { + setSuggestion(data); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load AI triage guidance"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); + return () => { + cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); + }; + }, [cveId, requestBody]); + + return ( +
+
+
+

AI Triage Agent

+

Read-only guidance built from severity, EPSS, references, KEV, project context, and the current triage record.

+
+ {suggestion?.requiresHumanApproval ? ( + + Human approval required + + ) : null} +
+ + {loading ?

Generating triage guidance...

: null} + {error ?

{error}

: null} + + {suggestion && !loading ? ( +
+

{suggestion.summary}

+ +
+ + + + +
+ +
+
+

Rationale

+

{suggestion.recommendation.rationale}

+
+
+

Ownership

+

{suggestion.ownershipRationale}

+
+
+ +
+ + + +
+ + {suggestion.recommendedTags.length > 0 ? ( +
+

Suggested Tags

+
+ {suggestion.recommendedTags.map((tag) => ( + + ))} +
+
+ ) : null} + +
+

Next Steps

+
    + {suggestion.recommendation.nextSteps.map((step) => ( +
  • {step}
  • + ))} +
+
+ +
+ {suggestion.recommendation.signals.map((signal) => ( +
+
+ {signal.label} + {signal.level} +
+

{signal.value}

+

{signal.rationale}

+
+ ))} +
+ +
+

Project Context

+

{suggestion.projectContext.summary}

+
+
+ ) : null} +
+ ); +} + +function Chip({ label, tone }: { label: string; tone: "red" | "cyan" | "gray" | "emerald" | "amber" }) { + const tones = { + red: "border-red-500/20 bg-red-500/10 text-red-200", + cyan: "border-cyan-500/20 bg-cyan-500/10 text-cyan-200", + gray: "border-white/[0.08] bg-white/[0.05] text-gray-300", + emerald: "border-emerald-500/20 bg-emerald-500/10 text-emerald-200", + amber: "border-amber-500/20 bg-amber-500/10 text-amber-200", + } as const; + + return {label}; +} diff --git a/src/components/TriagePanel.tsx b/src/components/TriagePanel.tsx index bcbd810..2264df3 100644 --- a/src/components/TriagePanel.tsx +++ b/src/components/TriagePanel.tsx @@ -10,8 +10,10 @@ import { TriageStatus, writeTriageRecord, } from "@/lib/triage"; +import { CVEDetail } from "@/lib/types"; +import AITriageAssistantPanel from "./AITriageAssistantPanel"; -export default function TriagePanel({ cveId }: { cveId: string }) { +export default function TriagePanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { const [record, setRecord] = useState(() => createDefaultTriageRecord(cveId)); const [tagInput, setTagInput] = useState(""); @@ -34,6 +36,12 @@ export default function TriagePanel({ cveId }: { cveId: string }) { }).then((saved) => setRecord(saved)); }; + const applySuggestion = (updater: (current: TriageRecord) => TriageRecord) => { + const next = updater(record); + persist(next); + setTagInput(next.tags.join(", ")); + }; + return (
@@ -115,6 +123,8 @@ export default function TriagePanel({ cveId }: { cveId: string }) {
)} + +
); } diff --git a/src/lib/ai-prompts.ts b/src/lib/ai-prompts.ts new file mode 100644 index 0000000..1eb13b0 --- /dev/null +++ b/src/lib/ai-prompts.ts @@ -0,0 +1,111 @@ +import { AIFeature } from "./types"; + +interface PromptTemplate { + feature: AIFeature; + version: string; + description: string; + build: (input: TInput) => string; +} + +interface SearchPromptInput { + request: string; + toolOutputs: unknown; +} + +export const AI_PROMPT_TEMPLATES = { + search_assistant: { + feature: "search_assistant", + version: "2026-03-07.search.v1", + description: "Translate a natural-language vulnerability search into deterministic filters.", + build: ({ request, toolOutputs }: SearchPromptInput) => [ + "Convert this vulnerability search request into structured filters.", + "Return only valid JSON with keys query, vendor, product, cwe, since, minSeverity, sort, explanation, assumptions, appliedFilters, needsClarification, clarificationQuestion.", + 'Allowed minSeverity: "ANY" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL".', + 'Allowed sort: "published_desc" | "published_asc" | "cvss_desc" | "cvss_asc" | "risk_desc".', + "Use the tool outputs below as the source of truth for extracted filters and clarifications.", + "Tool outputs:", + JSON.stringify(toolOutputs), + `Request: ${request}`, + ].join("\n"), + } satisfies PromptTemplate, + cve_insight: { + feature: "cve_insight", + version: "2026-03-07.insight.v1", + description: "Generate a triage-oriented CVE insight payload with remediation and project context.", + build: (input: unknown) => [ + "You are a security analyst assistant.", + "Return only valid JSON matching this TypeScript shape:", + '{"summary":"string","triage":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","confidence":"high|medium|low","ownerRecommendation":"string","rationale":"string","nextSteps":["string"],"signals":[{"label":"string","value":"string","level":"high|medium|low","rationale":"string"}]},"remediation":["string"],"cluster":{"canonicalId":"string","sourceIds":["string"],"relatedIds":["string"],"summary":"string"},"projectContext":{"projectCount":0,"projectNames":["string"],"summary":"string"}}', + "Base your answer only on this triage input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, + remediation_agent: { + feature: "remediation_agent", + version: "2026-03-07.remediation.v1", + description: "Draft remediation strategy, compensating controls, validation, and rollout notes for a CVE.", + build: (input: unknown) => [ + "You are a vulnerability remediation planning assistant.", + "Return only valid JSON matching this shape:", + '{"summary":"string","strategy":"string","compensatingControls":["string"],"validationSteps":["string"],"rolloutNotes":["string"],"changeRisk":"high|medium|low","recommendedOwner":"string","ownerRationale":"string","projectContext":{"projectCount":0,"projectNames":["string"],"summary":"string"},"requiresHumanApproval":true}', + "Base your answer only on this input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, + triage_agent: { + feature: "triage_agent", + version: "2026-03-07.triage.v1", + description: "Recommend triage priority, ownership, tags, and next actions for a single CVE.", + build: (input: unknown) => [ + "You are a vulnerability triage assistant.", + "Return only valid JSON matching this shape:", + '{"summary":"string","recommendation":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","confidence":"high|medium|low","ownerRecommendation":"string","rationale":"string","nextSteps":["string"],"signals":[{"label":"string","value":"string","level":"high|medium|low","rationale":"string"}]},"recommendedTags":["string"],"recommendedOwner":"string","ownershipRationale":"string","projectContext":{"projectCount":0,"projectNames":["string"],"summary":"string"},"requiresHumanApproval":true}', + "Base your answer only on this input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, + daily_digest: { + feature: "daily_digest", + version: "2026-03-07.digest.v1", + description: "Summarize watchlist, alerts, and projects into a concise monitoring digest.", + build: (input: unknown) => [ + "You are producing a concise vulnerability monitoring digest.", + "Return only valid JSON matching this shape:", + '{"headline":"string","sections":[{"title":"string","body":"string","items":["string"]}]}', + "Use this input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, +} as const; + +export function getPromptTemplate(feature: AIFeature) { + return AI_PROMPT_TEMPLATES[feature]; +} + +export function getSearchAssistantPromptTemplate() { + return AI_PROMPT_TEMPLATES.search_assistant; +} + +export function getCveInsightPromptTemplate() { + return AI_PROMPT_TEMPLATES.cve_insight; +} + +export function getDailyDigestPromptTemplate() { + return AI_PROMPT_TEMPLATES.daily_digest; +} + +export function getRemediationAgentPromptTemplate() { + return AI_PROMPT_TEMPLATES.remediation_agent; +} + +export function getTriageAgentPromptTemplate() { + return AI_PROMPT_TEMPLATES.triage_agent; +} + +export function listPromptTemplates() { + return Object.values(AI_PROMPT_TEMPLATES).map((template) => ({ + feature: template.feature, + version: template.version, + description: template.description, + })); +} diff --git a/src/lib/ai-runs-store.ts b/src/lib/ai-runs-store.ts index ac53d0c..39af044 100644 --- a/src/lib/ai-runs-store.ts +++ b/src/lib/ai-runs-store.ts @@ -136,7 +136,7 @@ function normalizeLimit(limit: number): number { } function isAIFeature(value: string): value is AIFeature { - return ["search_assistant", "cve_insight", "daily_digest"].includes(value); + return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent"].includes(value); } function isAIProvider(value: string): value is AIProvider { diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index e83d9e5..bdec4e1 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -2,9 +2,11 @@ import { AICveInsight, AIDigest, AIFeature, + AIRemediationPlan, AIRunRecord, AITriageContextSnapshot, AITriageSignal, + AITriageSuggestion, AIProvider, AISearchAppliedFilter, AISearchFilterField, @@ -19,6 +21,15 @@ import { import { appendAIRun, listRecentAIRuns } from "./ai-runs-store"; import { SearchState, normalizeSearchState } from "./search"; import { extractCVEId, extractDescription, getSeverityFromScore } from "./utils"; +import { + getCveInsightPromptTemplate, + getDailyDigestPromptTemplate, + getRemediationAgentPromptTemplate, + getSearchAssistantPromptTemplate, + getTriageAgentPromptTemplate, + listPromptTemplates, +} from "./ai-prompts"; +import { listAITools } from "./ai-tool-registry"; const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"; @@ -26,11 +37,13 @@ const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; const SEARCH_DEFAULT_SORT: SearchSortOption = "published_desc"; const SEARCH_DEFAULT_MIN_SEVERITY: SearchSeverityFilter = "ANY"; -const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest"]; +const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent"]; const AI_FEATURE_ENV_SEGMENTS: Record = { search_assistant: "SEARCH_ASSISTANT", cve_insight: "CVE_INSIGHT", daily_digest: "DAILY_DIGEST", + triage_agent: "TRIAGE_AGENT", + remediation_agent: "REMEDIATION_AGENT", }; export interface DigestInput { @@ -46,6 +59,9 @@ export interface CveInsightInput { relatedProjects: Pick[]; } +export type TriageSuggestionInput = CveInsightInput; +export type RemediationPlanInput = CveInsightInput; + export interface ServerAIConfigurationSummary { provider: AIProvider; model: string; @@ -59,6 +75,17 @@ export interface ServerAIConfigurationSummary { mode: "heuristic" | "configured"; configured: boolean; }>; + promptTemplates: Array<{ + feature: AIFeature; + version: string; + description: string; + }>; + toolRegistry: Array<{ + name: string; + description: string; + access: "read" | "write"; + features: AIFeature[]; + }>; } interface AIRuntimeSettings { @@ -135,6 +162,8 @@ export function getServerAIConfigurationSummary(): ServerAIConfigurationSummary mode: runtime.mode, configured: runtime.mode === "configured", availableProviders, + promptTemplates: listPromptTemplates(), + toolRegistry: listAITools(), featureConfigurations: AI_FEATURES.map((feature) => { const featureRuntime = resolveAIRuntime(feature); @@ -154,17 +183,35 @@ export async function getRecentAIRuns(limit = 25): Promise { } export async function generateCveInsight(input: CveInsightInput): Promise { + const promptTemplate = getCveInsightPromptTemplate(); return executeStructuredTask({ feature: "cve_insight", - prompt: [ - "You are a security analyst assistant.", - "Return only valid JSON matching this TypeScript shape:", - '{"summary":"string","triage":{"priority":"critical|high|medium|low","status":"new|investigating|mitigated|accepted|closed","confidence":"high|medium|low","ownerRecommendation":"string","rationale":"string","nextSteps":["string"],"signals":[{"label":"string","value":"string","level":"high|medium|low","rationale":"string"}]},"remediation":["string"],"cluster":{"canonicalId":"string","sourceIds":["string"],"relatedIds":["string"],"summary":"string"},"projectContext":{"projectCount":0,"projectNames":["string"],"summary":"string"}}', - "Base your answer only on this triage input JSON:", - JSON.stringify(input), - ].join("\n"), + prompt: promptTemplate.build(input), fallback: () => buildHeuristicCveInsight(input), sanitize: sanitizeInsight, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], + }); +} + +export async function generateTriageSuggestion(input: TriageSuggestionInput): Promise { + const promptTemplate = getTriageAgentPromptTemplate(); + return executeStructuredTask({ + feature: "triage_agent", + prompt: promptTemplate.build(input), + fallback: () => buildHeuristicTriageSuggestion(input), + sanitize: sanitizeTriageSuggestion, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], + }); +} + +export async function generateRemediationPlan(input: RemediationPlanInput): Promise { + const promptTemplate = getRemediationAgentPromptTemplate(); + return executeStructuredTask({ + feature: "remediation_agent", + prompt: promptTemplate.build(input), + fallback: () => buildHeuristicRemediationPlan(input), + sanitize: sanitizeRemediationPlan, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], }); } @@ -173,6 +220,8 @@ export async function generateSearchInterpretation(prompt: string): Promise { + const promptTemplate = getDailyDigestPromptTemplate(); return executeStructuredTask({ feature: "daily_digest", - prompt: [ - "You are producing a concise vulnerability monitoring digest.", - "Return only valid JSON matching this shape:", - '{"headline":"string","sections":[{"title":"string","body":"string","items":["string"]}]}', - "Use this input JSON:", - JSON.stringify(input), - ].join("\n"), + prompt: promptTemplate.build(input), fallback: () => buildHeuristicDigest(input), sanitize: sanitizeDigest, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], }); } @@ -300,6 +337,81 @@ export function buildHeuristicCveInsight(input: CVEDetail | CveInsightInput): AI }; } +export function buildHeuristicTriageSuggestion(input: TriageSuggestionInput | CVEDetail): AITriageSuggestion { + const normalized = normalizeCveInsightInput(input as CVEDetail | CveInsightInput); + const insight = buildHeuristicCveInsight(normalized); + const tags = buildRecommendedTags(normalized.detail, normalized.epss, normalized.triage, normalized.relatedProjects); + + return { + summary: insight.summary, + recommendation: insight.triage, + recommendedTags: tags, + recommendedOwner: deriveSuggestedOwner(normalized.triage, normalized.relatedProjects), + ownershipRationale: normalized.triage?.owner + ? `Keep ${normalized.triage.owner} unless product ownership has changed, because this CVE is already being handled in the current workflow.` + : insight.triage.ownerRecommendation, + projectContext: insight.projectContext, + requiresHumanApproval: true, + }; +} + +export function buildHeuristicRemediationPlan(input: RemediationPlanInput | CVEDetail): AIRemediationPlan { + const normalized = normalizeCveInsightInput(input as CVEDetail | CveInsightInput); + const insight = buildHeuristicCveInsight(normalized); + const referenceSummary = summarizeReferences(normalized.detail); + const severity = getSeverityFromScore(normalized.detail.cvss3 ?? normalized.detail.cvss); + const affectedProducts = (normalized.detail.containers?.cna?.affected ?? []) + .map((item) => item.product || item.vendor) + .filter((item): item is string => Boolean(item)) + .slice(0, 3); + const internetFacing = + normalized.triage?.tags.some((tag) => /internet-facing/i.test(tag)) || + /internet|remote|network|gateway|edge/i.test(extractDescription(normalized.detail)); + const recommendedOwner = deriveSuggestedOwner(normalized.triage, normalized.relatedProjects); + + return { + summary: `${extractCVEId(normalized.detail)} remediation should start with ${referenceSummary.patchCount > 0 ? "vendor patch validation" : "version exposure validation"}${affectedProducts.length > 0 ? ` for ${affectedProducts.join(", ")}` : ""}.`, + strategy: + referenceSummary.patchCount > 0 + ? "Validate the vendor-fixed release path first, then schedule rollout through the owning service team with a fallback to compensating controls if change windows are constrained." + : "Confirm affected versions in the environment, define an upgrade or isolation plan, and use compensating controls until a stable fix path is available.", + compensatingControls: [ + internetFacing ? "Prioritize edge filtering, WAF or reverse-proxy rules, and temporary exposure reduction for internet-facing entry points." : "Reduce exposure by limiting access to affected services while remediation is in progress.", + severity === "CRITICAL" || severity === "HIGH" + ? "Increase logging and detection coverage for exploit attempts tied to the vulnerable component." + : "Add targeted monitoring around the vulnerable component to catch failed or suspicious access patterns.", + referenceSummary.patchCount > 0 + ? "If rollout must wait, apply vendor-recommended mitigations from the published advisory references." + : "Document temporary configuration changes, feature flags, or service isolation steps that reduce exploitability.", + ], + validationSteps: [ + "Inventory deployed versions and confirm which environments actually run an affected build.", + referenceSummary.patchCount > 0 ? "Verify the selected fixed release or advisory guidance applies to each affected deployment path." : "Confirm the target upgrade or mitigation path with the owning engineering team before rollout.", + "Validate remediation with version checks, smoke tests, and review of logs or telemetry after deployment.", + ], + rolloutNotes: [ + normalized.relatedProjects.length > 0 + ? `Coordinate rollout with the linked projects: ${normalized.relatedProjects.map((project) => project.name).slice(0, 3).join(", ")}.` + : "Capture rollout ownership before making production changes.", + insight.triage.priority === "critical" || normalized.detail.kev + ? "Use the fastest approved change path and communicate urgency because severity or exploitation signals are elevated." + : "Prefer staged deployment with checkpoints for validation and rollback readiness.", + normalized.triage?.notes + ? `Preserve existing analyst notes during rollout: ${truncateSentence(normalized.triage.notes, 140)}` + : "Record validation evidence and any residual risk decisions in triage notes after rollout.", + ], + changeRisk: insight.triage.priority === "critical" || normalized.relatedProjects.length > 1 ? "high" : insight.triage.priority === "high" ? "medium" : "low", + recommendedOwner, + ownerRationale: normalized.triage?.owner + ? `Keep ${normalized.triage.owner} as remediation owner so the rollout stays aligned with the current analyst workflow.` + : normalized.relatedProjects.length > 0 + ? `Use the owning team for ${normalized.relatedProjects[0].name} because that project is already linked to the affected CVE.` + : "Assign the security or service owner who can validate exposure and coordinate the change window.", + projectContext: insight.projectContext, + requiresHumanApproval: true, + }; +} + function normalizeCveInsightInput(input: CVEDetail | CveInsightInput): CveInsightInput { if ("detail" in input) { return input; @@ -347,6 +459,57 @@ function buildProjectContext(projects: Pick[] +): string[] { + const tags = new Set(); + + const severity = getSeverityFromScore(detail.cvss3 ?? detail.cvss); + if (severity === "CRITICAL" || severity === "HIGH") { + tags.add(severity.toLowerCase()); + } + + if (detail.kev) { + tags.add("kev"); + } + + if ((epss?.percentile ?? 0) >= 0.9) { + tags.add("high-epss"); + } + + if (triage?.tags.some((tag) => /internet-facing/i.test(tag)) || (extractDescription(detail).match(/internet|remote|network/i))) { + tags.add("internet-facing"); + } + + if (projects.length > 0) { + tags.add("project-tracked"); + } + + if (summarizeReferences(detail).patchCount > 0) { + tags.add("patch-available"); + } + + return Array.from(tags).slice(0, 5); +} + +function deriveSuggestedOwner( + triage: AITriageContextSnapshot | null, + projects: Pick[] +): string { + if (triage?.owner) { + return triage.owner; + } + + if (projects.length > 0) { + return `${projects[0].name} owner`; + } + + return "Security triage"; +} + function buildTriageSignals(input: { severity: ReturnType; severityScore?: number; @@ -542,7 +705,7 @@ async function executeStructuredTask({ feature, prompt, fallback, sanitize, t try { const response = await callModel(prompt, runtime, feature); - const result = sanitize(JSON.parse(response)); + const result = sanitize(parseModelJSON(response, feature)); await persistAIRun({ feature, runtime, @@ -802,6 +965,14 @@ async function persistAIRun(input: { } } +function parseModelJSON(response: string, feature: AIFeature): unknown { + try { + return JSON.parse(response); + } catch (error) { + throw new Error(`${feature}_invalid_json:${error instanceof Error ? error.message : "parse_failed"}`); + } +} + function safeSerialize(value: unknown): string { try { return JSON.stringify(value); @@ -1043,6 +1214,65 @@ function sanitizeDigest(value: unknown): AIDigest { }; } +function sanitizeTriageSuggestion(value: unknown): AITriageSuggestion { + const fallback = buildHeuristicTriageSuggestion({ + detail: { id: "CVE-UNKNOWN" }, + epss: null, + triage: null, + relatedProjects: [], + }); + + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + recommendation: sanitizeTriage(record.recommendation, fallback.recommendation), + recommendedTags: Array.isArray(record.recommendedTags) + ? record.recommendedTags.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.recommendedTags, + recommendedOwner: typeof record.recommendedOwner === "string" ? record.recommendedOwner : fallback.recommendedOwner, + ownershipRationale: typeof record.ownershipRationale === "string" ? record.ownershipRationale : fallback.ownershipRationale, + projectContext: sanitizeProjectContext(record.projectContext, fallback.projectContext), + requiresHumanApproval: record.requiresHumanApproval === false ? false : true, + }; +} + +function sanitizeRemediationPlan(value: unknown): AIRemediationPlan { + const fallback = buildHeuristicRemediationPlan({ + detail: { id: "CVE-UNKNOWN" }, + epss: null, + triage: null, + relatedProjects: [], + }); + + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + strategy: typeof record.strategy === "string" ? record.strategy : fallback.strategy, + compensatingControls: Array.isArray(record.compensatingControls) + ? record.compensatingControls.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.compensatingControls, + validationSteps: Array.isArray(record.validationSteps) + ? record.validationSteps.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.validationSteps, + rolloutNotes: Array.isArray(record.rolloutNotes) + ? record.rolloutNotes.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.rolloutNotes, + changeRisk: record.changeRisk === "high" || record.changeRisk === "medium" || record.changeRisk === "low" ? record.changeRisk : fallback.changeRisk, + recommendedOwner: typeof record.recommendedOwner === "string" ? record.recommendedOwner : fallback.recommendedOwner, + ownerRationale: typeof record.ownerRationale === "string" ? record.ownerRationale : fallback.ownerRationale, + projectContext: sanitizeProjectContext(record.projectContext, fallback.projectContext), + requiresHumanApproval: record.requiresHumanApproval === false ? false : true, + }; +} + function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICveInsight["triage"] { if (!value || typeof value !== "object") return fallback; const record = value as Record; diff --git a/src/lib/ai-tool-registry.ts b/src/lib/ai-tool-registry.ts new file mode 100644 index 0000000..58527c8 --- /dev/null +++ b/src/lib/ai-tool-registry.ts @@ -0,0 +1,67 @@ +import { AIFeature } from "./types"; + +export interface AIToolDefinition { + name: string; + description: string; + access: "read" | "write"; + features: AIFeature[]; +} + +const AI_TOOL_REGISTRY: AIToolDefinition[] = [ + { + name: "search_cves", + description: "Search CVE summaries with structured filters and prioritization.", + access: "read", + features: ["search_assistant", "daily_digest"], + }, + { + name: "fetch_cve_details", + description: "Fetch detailed CVE records, severity data, references, and exploit context.", + access: "read", + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent"], + }, + { + name: "read_watchlist_state", + description: "Read workspace watchlist records and tracked CVE identifiers.", + access: "read", + features: ["daily_digest"], + }, + { + name: "read_alert_rule_matches", + description: "Read alert rules and the CVEs that currently match them.", + access: "read", + features: ["daily_digest"], + }, + { + name: "read_project_records", + description: "Read project groupings, audit history, and linked CVE items.", + access: "read", + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent"], + }, + { + name: "write_project_records", + description: "Add or update project records after an explicit approval checkpoint.", + access: "write", + features: ["cve_insight"], + }, + { + name: "read_triage_state", + description: "Read user-scoped triage notes, ownership, tags, and workflow status.", + access: "read", + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent"], + }, + { + name: "write_triage_state", + description: "Update triage state after an explicit human approval checkpoint.", + access: "write", + features: ["cve_insight", "triage_agent"], + }, +]; + +export function listAITools(feature?: AIFeature): AIToolDefinition[] { + if (!feature) { + return [...AI_TOOL_REGISTRY]; + } + + return AI_TOOL_REGISTRY.filter((tool) => tool.features.includes(feature)); +} diff --git a/src/lib/ai.ts b/src/lib/ai.ts index b5c8480..b990d27 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -3,9 +3,13 @@ import { AISettings } from "./types"; export { buildHeuristicCveInsight, buildHeuristicDigest, + buildHeuristicRemediationPlan, + buildHeuristicTriageSuggestion, generateCveInsight, generateDigest, + generateRemediationPlan, generateSearchInterpretation, + generateTriageSuggestion, getRecentAIRuns, getServerAIConfigurationSummary, interpretSearchPromptHeuristically, @@ -14,7 +18,9 @@ export { export type { CveInsightInput, DigestInput, + RemediationPlanInput, ServerAIConfigurationSummary, + TriageSuggestionInput, } from "./ai-service"; const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; diff --git a/src/lib/types.ts b/src/lib/types.ts index cd45e2c..2516ca4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -269,6 +269,29 @@ export interface AICveInsight { projectContext: AIProjectContext; } +export interface AITriageSuggestion { + summary: string; + recommendation: AITriageRecommendation; + recommendedTags: string[]; + recommendedOwner: string; + ownershipRationale: string; + projectContext: AIProjectContext; + requiresHumanApproval: boolean; +} + +export interface AIRemediationPlan { + summary: string; + strategy: string; + compensatingControls: string[]; + validationSteps: string[]; + rolloutNotes: string[]; + changeRisk: "high" | "medium" | "low"; + recommendedOwner: string; + ownerRationale: string; + projectContext: AIProjectContext; + requiresHumanApproval: boolean; +} + export type AISearchFilterField = "query" | "vendor" | "product" | "cwe" | "since" | "minSeverity" | "sort"; export interface AISearchAppliedFilter { @@ -328,7 +351,7 @@ export interface AIRunRecord { export type AIProvider = "heuristic" | "openai" | "anthropic"; -export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest"; +export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent"; export interface AISettings { provider: AIProvider; diff --git a/tests/ai-platform.test.ts b/tests/ai-platform.test.ts new file mode 100644 index 0000000..a22e2c6 --- /dev/null +++ b/tests/ai-platform.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { getServerAIConfigurationSummary, interpretSearchPromptHeuristically } from "../src/lib/ai"; + +test("AI configuration summary exposes prompt versions and tool registry", () => { + const summary = getServerAIConfigurationSummary(); + + assert.equal(summary.promptTemplates.length >= 3, true); + assert.equal(summary.promptTemplates.every((template) => template.version.includes("2026-03-07")), true); + assert.equal(summary.toolRegistry.some((tool) => tool.name === "search_cves"), true); + assert.equal(summary.toolRegistry.some((tool) => tool.name === "write_triage_state"), true); +}); + +test("search assistant evaluation fixtures remain stable", () => { + const fixturePath = path.join(process.cwd(), "tests", "fixtures", "ai-search-evals.json"); + const cases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<{ + prompt: string; + expected: { + minSeverity: string; + sort: string; + queryIncludes: string; + requiresClarification: boolean; + hasSince: boolean; + }; + }>; + + for (const item of cases) { + const result = interpretSearchPromptHeuristically(item.prompt); + assert.equal(result.minSeverity, item.expected.minSeverity); + assert.equal(result.sort, item.expected.sort); + assert.equal(result.needsClarification, item.expected.requiresClarification); + assert.equal(Boolean(result.since), item.expected.hasSince); + + if (item.expected.queryIncludes) { + assert.match(result.query.toLowerCase(), new RegExp(item.expected.queryIncludes)); + } + } +}); diff --git a/tests/ai.test.ts b/tests/ai.test.ts index 093244a..fa20ee5 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import { buildHeuristicCveInsight, buildHeuristicDigest, + buildHeuristicRemediationPlan, + buildHeuristicTriageSuggestion, getServerAIConfigurationSummary, interpretSearchPromptHeuristically, } from "../src/lib/ai"; @@ -103,34 +105,137 @@ test("buildHeuristicDigest summarizes watchlist, alerts, and projects", () => { assert.equal(result.sections.length, 3); }); +test("buildHeuristicTriageSuggestion recommends owner, tags, and explicit approval", () => { + const result = buildHeuristicTriageSuggestion({ + detail: { + id: "CVE-2026-3333", + cvss3: 9.1, + summary: "Critical remote code execution in an internet-facing gateway", + kev: { + cveID: "CVE-2026-3333", + vendorProject: "Acme", + product: "Gateway", + vulnerabilityName: "Gateway RCE", + dateAdded: "2026-03-01", + shortDescription: "Known exploited gateway issue", + requiredAction: "Patch immediately", + dueDate: "2026-03-08", + }, + references: ["https://vendor.example/patch", "https://research.example/exploit-poc"], + }, + epss: { + cve: "CVE-2026-3333", + epss: 0.91, + percentile: 0.99, + }, + triage: { + status: "new", + owner: "", + notes: "", + tags: ["internet-facing"], + updatedAt: "2026-03-07T10:00:00.000Z", + }, + relatedProjects: [ + { + name: "Gateway Platform", + updatedAt: "2026-03-07T10:00:00.000Z", + items: [{ cveId: "CVE-2026-3333", addedAt: "2026-03-07T09:00:00.000Z" }], + }, + ], + }); + + assert.equal(result.recommendation.priority, "critical"); + assert.equal(result.requiresHumanApproval, true); + assert.equal(result.recommendedTags.includes("kev"), true); + assert.equal(result.recommendedTags.includes("high-epss"), true); + assert.match(result.recommendedOwner, /Gateway Platform owner|Security triage/i); +}); + +test("buildHeuristicRemediationPlan drafts controls, validation, and rollout notes", () => { + const result = buildHeuristicRemediationPlan({ + detail: { + id: "CVE-2026-4444", + cvss3: 8.8, + summary: "High severity issue in an edge gateway", + references: ["https://vendor.example/advisory", "https://vendor.example/patch"], + containers: { + cna: { + affected: [{ vendor: "Acme", product: "Edge Gateway" }], + references: [{ url: "https://vendor.example/patch", tags: ["patch"] }], + }, + }, + }, + epss: { + cve: "CVE-2026-4444", + epss: 0.77, + percentile: 0.91, + }, + triage: { + status: "investigating", + owner: "platform-security", + notes: "Internet-facing edge service", + tags: ["internet-facing"], + updatedAt: "2026-03-07T11:00:00.000Z", + }, + relatedProjects: [ + { + name: "Edge Gateway", + updatedAt: "2026-03-07T10:00:00.000Z", + items: [{ cveId: "CVE-2026-4444", addedAt: "2026-03-07T09:00:00.000Z" }], + }, + ], + }); + + assert.match(result.summary, /patch validation|version exposure validation/i); + assert.equal(result.compensatingControls.length >= 3, true); + assert.equal(result.validationSteps.length >= 3, true); + assert.equal(result.rolloutNotes.length >= 3, true); + assert.equal(result.requiresHumanApproval, true); + assert.equal(result.recommendedOwner, "platform-security"); +}); + test("getServerAIConfigurationSummary applies per-feature provider and model overrides", () => { const previous = { AI_PROVIDER: process.env.AI_PROVIDER, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, AI_SEARCH_ASSISTANT_PROVIDER: process.env.AI_SEARCH_ASSISTANT_PROVIDER, AI_SEARCH_ASSISTANT_MODEL: process.env.AI_SEARCH_ASSISTANT_MODEL, AI_CVE_INSIGHT_PROVIDER: process.env.AI_CVE_INSIGHT_PROVIDER, AI_CVE_INSIGHT_MODEL: process.env.AI_CVE_INSIGHT_MODEL, AI_DAILY_DIGEST_PROVIDER: process.env.AI_DAILY_DIGEST_PROVIDER, AI_DAILY_DIGEST_MODEL: process.env.AI_DAILY_DIGEST_MODEL, + AI_TRIAGE_AGENT_PROVIDER: process.env.AI_TRIAGE_AGENT_PROVIDER, + AI_TRIAGE_AGENT_MODEL: process.env.AI_TRIAGE_AGENT_MODEL, + AI_REMEDIATION_AGENT_PROVIDER: process.env.AI_REMEDIATION_AGENT_PROVIDER, + AI_REMEDIATION_AGENT_MODEL: process.env.AI_REMEDIATION_AGENT_MODEL, }; process.env.AI_PROVIDER = "openai"; process.env.OPENAI_API_KEY = "test-openai-key"; process.env.OPENAI_MODEL = "gpt-global"; + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + process.env.ANTHROPIC_MODEL = "claude-global"; process.env.AI_SEARCH_ASSISTANT_PROVIDER = "heuristic"; process.env.AI_SEARCH_ASSISTANT_MODEL = "ignored-search-model"; process.env.AI_CVE_INSIGHT_PROVIDER = "openai"; process.env.AI_CVE_INSIGHT_MODEL = "gpt-cve"; process.env.AI_DAILY_DIGEST_PROVIDER = "openai"; process.env.AI_DAILY_DIGEST_MODEL = "gpt-digest"; + process.env.AI_TRIAGE_AGENT_PROVIDER = "anthropic"; + process.env.AI_TRIAGE_AGENT_MODEL = "claude-triage"; + process.env.AI_REMEDIATION_AGENT_PROVIDER = "openai"; + process.env.AI_REMEDIATION_AGENT_MODEL = "gpt-remediation"; try { const summary = getServerAIConfigurationSummary(); const search = summary.featureConfigurations.find((item) => item.feature === "search_assistant"); const cveInsight = summary.featureConfigurations.find((item) => item.feature === "cve_insight"); const digest = summary.featureConfigurations.find((item) => item.feature === "daily_digest"); + const triageAgent = summary.featureConfigurations.find((item) => item.feature === "triage_agent"); + const remediationAgent = summary.featureConfigurations.find((item) => item.feature === "remediation_agent"); assert.equal(summary.provider, "openai"); assert.equal(summary.model, "gpt-global"); @@ -141,6 +246,10 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove assert.equal(cveInsight?.model, "gpt-cve"); assert.equal(digest?.provider, "openai"); assert.equal(digest?.model, "gpt-digest"); + assert.equal(triageAgent?.provider, "anthropic"); + assert.equal(triageAgent?.model, "claude-triage"); + assert.equal(remediationAgent?.provider, "openai"); + assert.equal(remediationAgent?.model, "gpt-remediation"); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) { diff --git a/tests/fixtures/ai-search-evals.json b/tests/fixtures/ai-search-evals.json new file mode 100644 index 0000000..185216b --- /dev/null +++ b/tests/fixtures/ai-search-evals.json @@ -0,0 +1,32 @@ +[ + { + "prompt": "show me critical OpenSSL vulns from this month", + "expected": { + "minSeverity": "CRITICAL", + "sort": "cvss_desc", + "queryIncludes": "openssl", + "requiresClarification": false, + "hasSince": true + } + }, + { + "prompt": "kev ransomware cves this week", + "expected": { + "minSeverity": "ANY", + "sort": "risk_desc", + "queryIncludes": "ransomware", + "requiresClarification": false, + "hasSince": true + } + }, + { + "prompt": "recent", + "expected": { + "minSeverity": "ANY", + "sort": "published_desc", + "queryIncludes": "", + "requiresClarification": true, + "hasSince": false + } + } +] From 294cbaf01ec60641b4ecf0d67a54cac56a42815e Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:35:08 +0100 Subject: [PATCH 15/30] feat(ai): add watchlist analyst review --- docs/todo.md | 5 +- src/app/api/ai/watchlist/review/route.ts | 105 ++++++++++ src/components/AISettingsPageClient.tsx | 2 +- src/components/AIWatchlistReviewPanel.tsx | 155 +++++++++++++++ src/components/WatchlistPageClient.tsx | 5 + src/lib/ai-prompts.ts | 16 ++ src/lib/ai-runs-store.ts | 2 +- src/lib/ai-service.ts | 222 +++++++++++++++++++++- src/lib/ai-tool-registry.ts | 10 +- src/lib/ai.ts | 3 + src/lib/types.ts | 19 +- src/lib/workspace-store.ts | 9 + tests/ai.test.ts | 54 ++++++ 13 files changed, 596 insertions(+), 11 deletions(-) create mode 100644 src/app/api/ai/watchlist/review/route.ts create mode 100644 src/components/AIWatchlistReviewPanel.tsx diff --git a/docs/todo.md b/docs/todo.md index 338ed83..255273a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,6 +3,7 @@ ## misc stuff - [x] make the UI use 95% of the screen width +- [ ] persist scan results in "repos" module in db. ## Recommended Build Order @@ -41,7 +42,7 @@ - [x] improve result cards with stronger severity, EPSS, KEV, and recency cues - [x] add bulk actions for watchlist, triage, and project assignment - [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability -- add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review +- [x] add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review - add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view - add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications @@ -117,7 +118,7 @@ - [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches - [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions - [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability -- add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review +- [x] add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review - add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action - [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view diff --git a/src/app/api/ai/watchlist/review/route.ts b/src/app/api/ai/watchlist/review/route.ts new file mode 100644 index 0000000..bae9533 --- /dev/null +++ b/src/app/api/ai/watchlist/review/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateWatchlistReview, getRecentAIRuns } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer } from "@/lib/server-api"; +import { readTriageMapForUser, listWatchlistEntriesForUser } from "@/lib/workspace-store"; +import { CVEDetail } from "@/lib/types"; +import { extractDescription, extractCVEId, getSeverityFromScore } from "@/lib/utils"; + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const [watchlistEntries, triageMap, projects, recentRuns] = await Promise.all([ + listWatchlistEntriesForUser(session.userId), + readTriageMapForUser(session.userId), + listProjects().catch(() => []), + getRecentAIRuns(100).catch(() => []), + ]); + + const details = await Promise.all( + watchlistEntries.map(async (entry) => { + try { + const detail = await getCVEByIdServer(entry.cveId); + return { entry, detail }; + } catch { + return null; + } + }) + ); + + const previousReviewAt = recentRuns.find((run) => run.feature === "watchlist_analyst")?.createdAt ?? null; + const items = details.flatMap((result) => { + if (!result) { + return []; + } + + const cveId = extractCVEId(result.detail); + const triage = triageMap[cveId]; + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === cveId)); + return [{ + id: cveId, + summary: extractDescription(result.detail), + severity: getSeverityFromScore(result.detail.cvss3 ?? result.detail.cvss), + kev: Boolean(result.detail.kev), + addedAt: result.entry.addedAt, + triageStatus: triage?.status ?? "new", + triageUpdatedAt: triage?.updatedAt ?? result.entry.addedAt, + projectNames: relatedProjects.map((project) => project.name), + projectUpdatedAt: relatedProjects[0]?.updatedAt ?? null, + aliases: result.detail.aliases ?? [], + relatedIds: extractRelatedIds(result.detail), + affectedProducts: extractAffectedProducts(result.detail), + published: result.detail.cveMetadata?.datePublished || result.detail.published || result.entry.addedAt, + modified: result.detail.cveMetadata?.dateUpdated || result.detail.modified || result.entry.addedAt, + }]; + }); + + const review = await generateWatchlistReview({ + items, + previousReviewAt, + }); + + return applyWorkspaceSession(NextResponse.json(review), session); +}, { + route: "/api/ai/watchlist/review", + errorMessage: "Failed to generate AI watchlist review", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function extractRelatedIds(detail: CVEDetail): string[] { + const related = new Set(); + const record = detail as unknown as Record; + + for (const alias of detail.aliases ?? []) { + related.add(alias); + } + + for (const key of ["linked_vulnerabilities", "related_vulnerabilities", "vulnerabilities", "related"]) { + const value = record[key]; + if (!Array.isArray(value)) continue; + for (const item of value) { + if (typeof item === "string") { + related.add(item); + continue; + } + + if (item && typeof item === "object") { + const objectValue = item as Record; + for (const field of ["id", "cve", "vulnerability"]) { + if (typeof objectValue[field] === "string") { + related.add(objectValue[field] as string); + } + } + } + } + } + + related.delete(extractCVEId(detail)); + return Array.from(related).slice(0, 8); +} + +function extractAffectedProducts(detail: CVEDetail): string[] { + const items = detail.containers?.cna?.affected?.flatMap((entry) => [entry.product, entry.vendor].filter((value): value is string => Boolean(value))) ?? []; + return Array.from(new Set(items)).slice(0, 6); +} diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 92fd978..8a5c788 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -37,7 +37,7 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: - Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. diff --git a/src/components/AIWatchlistReviewPanel.tsx b/src/components/AIWatchlistReviewPanel.tsx new file mode 100644 index 0000000..fd3b441 --- /dev/null +++ b/src/components/AIWatchlistReviewPanel.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AIRunRecord, AIWatchlistReview } from "@/lib/types"; +import { TRIAGE_UPDATED_EVENT } from "@/lib/triage"; +import { WATCHLIST_UPDATED_EVENT } from "@/lib/watchlist"; + +export default function AIWatchlistReviewPanel({ watchlistCount }: { watchlistCount: number }) { + const [review, setReview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [lastReviewAt, setLastReviewAt] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadLastReview() { + try { + const res = await fetch("/api/ai/runs?limit=50", { cache: "no-store" }); + const data = await res.json().catch(() => []); + if (!res.ok || cancelled || !Array.isArray(data)) { + return; + } + + const latest = data.find((item): item is AIRunRecord => Boolean(item) && typeof item === "object" && (item as AIRunRecord).feature === "watchlist_analyst"); + if (latest?.createdAt) { + setLastReviewAt(latest.createdAt); + } + } catch { + } + } + + void loadLastReview(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const markStale = () => setStale(true); + window.addEventListener(WATCHLIST_UPDATED_EVENT, markStale); + window.addEventListener(TRIAGE_UPDATED_EVENT, markStale); + return () => { + window.removeEventListener(WATCHLIST_UPDATED_EVENT, markStale); + window.removeEventListener(TRIAGE_UPDATED_EVENT, markStale); + }; + }, []); + + async function handleGenerate() { + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/ai/watchlist/review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to generate AI watchlist review"); + } + + setReview(data); + setLastReviewAt(data?.reviewedAt ?? null); + setStale(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate AI watchlist review"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

AI Watchlist Analyst

+

Reviews tracked watchlist items, highlights changes since the last saved review, and clusters related issues for faster analyst triage.

+
+ +
+ +
+ {watchlistCount} tracked {watchlistCount === 1 ? "item" : "items"} + {lastReviewAt ? Last review: {new Date(lastReviewAt).toLocaleString("en-US")} : No saved review yet} + {stale && review ? Workspace changed since this review : null} +
+ + {watchlistCount === 0 ?

Add CVEs to the watchlist to generate an analyst review.

: null} + {error ?

{error}

: null} + + {review ? ( +
+
+
{review.headline}
+

{review.summary}

+
+ + + + +
+

Related Clusters

+ {review.clusters.length > 0 ? ( +
+ {review.clusters.map((cluster) => ( +
+
+ {cluster.label} + {cluster.cveIds.map((cveId) => ( + {cveId} + ))} +
+

{cluster.summary}

+
+ ))} +
+ ) : ( +

No multi-item clusters were detected in the current watchlist.

+ )} +
+ + +
+ ) : null} +
+ ); +} + +function ReviewList({ title, items, emptyLabel }: { title: string; items: string[]; emptyLabel: string }) { + return ( +
+

{title}

+ {items.length > 0 ? ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ) : ( +

{emptyLabel}

+ )} +
+ ); +} diff --git a/src/components/WatchlistPageClient.tsx b/src/components/WatchlistPageClient.tsx index f4c3357..e09a964 100644 --- a/src/components/WatchlistPageClient.tsx +++ b/src/components/WatchlistPageClient.tsx @@ -17,6 +17,7 @@ import { import { ProjectRecord } from "@/lib/types"; import CVEList from "@/components/CVEList"; import AIDigestPanel from "@/components/AIDigestPanel"; +import AIWatchlistReviewPanel from "@/components/AIWatchlistReviewPanel"; export default function WatchlistPageClient() { const [items, setItems] = useState([]); @@ -358,6 +359,10 @@ export default function WatchlistPageClient() {
)} +
+ +
+
diff --git a/src/lib/ai-prompts.ts b/src/lib/ai-prompts.ts index 1eb13b0..b60d172 100644 --- a/src/lib/ai-prompts.ts +++ b/src/lib/ai-prompts.ts @@ -52,6 +52,18 @@ export const AI_PROMPT_TEMPLATES = { JSON.stringify(input), ].join("\n"), } satisfies PromptTemplate, + watchlist_analyst: { + feature: "watchlist_analyst", + version: "2026-03-07.watchlist.v1", + description: "Review watchlist changes since the last analyst pass and cluster related issues.", + build: (input: unknown) => [ + "You are a watchlist analyst assistant.", + "Return only valid JSON matching this shape:", + '{"headline":"string","summary":"string","newMatches":["string"],"changedSinceLastReview":["string"],"clusters":[{"label":"string","cveIds":["string"],"summary":"string"}],"recommendedActions":["string"],"previousReviewAt":"string|null","reviewedAt":"string"}', + "Base your answer only on this input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, triage_agent: { feature: "triage_agent", version: "2026-03-07.triage.v1", @@ -98,6 +110,10 @@ export function getRemediationAgentPromptTemplate() { return AI_PROMPT_TEMPLATES.remediation_agent; } +export function getWatchlistAnalystPromptTemplate() { + return AI_PROMPT_TEMPLATES.watchlist_analyst; +} + export function getTriageAgentPromptTemplate() { return AI_PROMPT_TEMPLATES.triage_agent; } diff --git a/src/lib/ai-runs-store.ts b/src/lib/ai-runs-store.ts index 39af044..97af85d 100644 --- a/src/lib/ai-runs-store.ts +++ b/src/lib/ai-runs-store.ts @@ -136,7 +136,7 @@ function normalizeLimit(limit: number): number { } function isAIFeature(value: string): value is AIFeature { - return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent"].includes(value); + return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"].includes(value); } function isAIProvider(value: string): value is AIProvider { diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index bdec4e1..054019d 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -12,6 +12,7 @@ import { AISearchFilterField, AISearchInterpretation, AISearchToolTrace, + AIWatchlistReview, CVEDetail, EPSSData, ProjectRecord, @@ -27,6 +28,7 @@ import { getRemediationAgentPromptTemplate, getSearchAssistantPromptTemplate, getTriageAgentPromptTemplate, + getWatchlistAnalystPromptTemplate, listPromptTemplates, } from "./ai-prompts"; import { listAITools } from "./ai-tool-registry"; @@ -37,13 +39,14 @@ const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; const SEARCH_DEFAULT_SORT: SearchSortOption = "published_desc"; const SEARCH_DEFAULT_MIN_SEVERITY: SearchSeverityFilter = "ANY"; -const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent"]; +const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"]; const AI_FEATURE_ENV_SEGMENTS: Record = { search_assistant: "SEARCH_ASSISTANT", cve_insight: "CVE_INSIGHT", daily_digest: "DAILY_DIGEST", triage_agent: "TRIAGE_AGENT", remediation_agent: "REMEDIATION_AGENT", + watchlist_analyst: "WATCHLIST_ANALYST", }; export interface DigestInput { @@ -62,6 +65,26 @@ export interface CveInsightInput { export type TriageSuggestionInput = CveInsightInput; export type RemediationPlanInput = CveInsightInput; +export interface WatchlistReviewInput { + items: Array<{ + id: string; + summary: string; + severity: SearchSeverityFilter | "NONE" | "UNKNOWN"; + kev: boolean; + addedAt: string; + triageStatus: AITriageContextSnapshot["status"]; + triageUpdatedAt: string; + projectNames: string[]; + projectUpdatedAt: string | null; + aliases: string[]; + relatedIds: string[]; + affectedProducts: string[]; + published: string; + modified: string; + }>; + previousReviewAt: string | null; +} + export interface ServerAIConfigurationSummary { provider: AIProvider; model: string; @@ -215,6 +238,17 @@ export async function generateRemediationPlan(input: RemediationPlanInput): Prom }); } +export async function generateWatchlistReview(input: WatchlistReviewInput): Promise { + const promptTemplate = getWatchlistAnalystPromptTemplate(); + return executeStructuredTask({ + feature: "watchlist_analyst", + prompt: promptTemplate.build(input), + fallback: () => buildHeuristicWatchlistReview(input), + sanitize: sanitizeWatchlistReview, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], + }); +} + export async function generateSearchInterpretation(prompt: string): Promise { const plan = runSearchPlanning(prompt); const heuristic = buildSearchInterpretationFromPlan(prompt, plan); @@ -412,6 +446,53 @@ export function buildHeuristicRemediationPlan(input: RemediationPlanInput | CVED }; } +export function buildHeuristicWatchlistReview(input: WatchlistReviewInput): AIWatchlistReview { + const reviewedAt = new Date().toISOString(); + const sortedItems = [...input.items].sort((left, right) => compareWatchlistRisk(right, left)); + const previousReviewAt = input.previousReviewAt; + const newMatches = previousReviewAt + ? sortedItems.filter((item) => item.addedAt > previousReviewAt) + : sortedItems.slice(0, Math.min(sortedItems.length, 5)); + const changedItems = previousReviewAt + ? sortedItems.filter((item) => { + const projectChanged = item.projectUpdatedAt ? item.projectUpdatedAt > previousReviewAt : false; + return item.triageUpdatedAt > previousReviewAt || projectChanged || item.modified > previousReviewAt; + }) + : []; + const clusters = buildWatchlistClusters(sortedItems); + const trackedHighRisk = sortedItems.filter((item) => item.kev || item.severity === "CRITICAL" || item.severity === "HIGH").length; + + return { + headline: + newMatches.length > 0 + ? `${newMatches.length} watchlist ${newMatches.length === 1 ? "change" : "changes"} need review` + : trackedHighRisk > 0 + ? `${trackedHighRisk} high-risk watchlist ${trackedHighRisk === 1 ? "item" : "items"} remain active` + : `Watchlist review covers ${sortedItems.length} tracked ${sortedItems.length === 1 ? "item" : "items"}`, + summary: previousReviewAt + ? `Compared the current watchlist against the last saved review from ${new Date(previousReviewAt).toLocaleString("en-US")}. Focus on newly added matches, triage changes, and related clusters that may need coordinated handling.` + : "No prior watchlist review was saved, so this pass establishes a first analyst baseline from the current tracked items.", + newMatches: newMatches.slice(0, 6).map((item) => `${item.id} • ${buildWatchlistItemSummary(item)}`), + changedSinceLastReview: changedItems.slice(0, 8).map((item) => { + const reasons: string[] = []; + if (previousReviewAt && item.triageUpdatedAt > previousReviewAt) { + reasons.push(`triage is now ${item.triageStatus}`); + } + if (previousReviewAt && item.projectUpdatedAt && item.projectUpdatedAt > previousReviewAt) { + reasons.push(`project context changed (${item.projectNames.join(", ")})`); + } + if (previousReviewAt && item.modified > previousReviewAt) { + reasons.push("upstream record was updated"); + } + return `${item.id} • ${reasons.join("; ") || buildWatchlistItemSummary(item)}`; + }), + clusters, + recommendedActions: buildWatchlistActions(sortedItems, newMatches, changedItems, clusters), + previousReviewAt, + reviewedAt, + }; +} + function normalizeCveInsightInput(input: CVEDetail | CveInsightInput): CveInsightInput { if ("detail" in input) { return input; @@ -510,6 +591,104 @@ function deriveSuggestedOwner( return "Security triage"; } +function compareWatchlistRisk(left: WatchlistReviewInput["items"][number], right: WatchlistReviewInput["items"][number]): number { + return scoreWatchlistItem(left) - scoreWatchlistItem(right); +} + +function scoreWatchlistItem(item: WatchlistReviewInput["items"][number]): number { + const severityScore = item.severity === "CRITICAL" + ? 5 + : item.severity === "HIGH" + ? 4 + : item.severity === "MEDIUM" + ? 3 + : item.severity === "LOW" + ? 2 + : 1; + + return severityScore + (item.kev ? 3 : 0) + (item.projectNames.length > 0 ? 1 : 0) + (item.triageStatus === "new" ? 1 : 0); +} + +function buildWatchlistItemSummary(item: WatchlistReviewInput["items"][number]): string { + const parts = [item.severity.toLowerCase()]; + + if (item.kev) { + parts.push("KEV"); + } + + if (item.projectNames.length > 0) { + parts.push(`projects: ${item.projectNames.join(", ")}`); + } + + parts.push(`triage: ${item.triageStatus}`); + return parts.join(" • "); +} + +function buildWatchlistClusters(items: WatchlistReviewInput["items"]): AIWatchlistReview["clusters"] { + const buckets = new Map(); + + for (const item of items) { + const labels = new Set(); + const primaryProduct = item.affectedProducts[0]; + if (primaryProduct) { + labels.add(primaryProduct.toLowerCase()); + } + + for (const relatedId of item.relatedIds.slice(0, 2)) { + labels.add(`cluster:${relatedId.toLowerCase()}`); + } + + for (const label of labels) { + const current = buckets.get(label) ?? []; + current.push(item); + buckets.set(label, current); + } + } + + return Array.from(buckets.entries()) + .filter(([, grouped]) => grouped.length > 1) + .slice(0, 4) + .map(([label, grouped]) => ({ + label: label.startsWith("cluster:") ? `Linked to ${label.slice("cluster:".length).toUpperCase()}` : label, + cveIds: Array.from(new Set(grouped.map((item) => item.id))).slice(0, 6), + summary: label.startsWith("cluster:") + ? "These watchlist entries share linked-vulnerability context and may belong in the same analyst thread." + : `These entries share affected product context around ${label} and may benefit from coordinated review.`, + })); +} + +function buildWatchlistActions( + items: WatchlistReviewInput["items"], + newMatches: WatchlistReviewInput["items"], + changedItems: WatchlistReviewInput["items"], + clusters: AIWatchlistReview["clusters"] +): string[] { + const actions: string[] = []; + + if (newMatches.length > 0) { + actions.push(`Triage the ${newMatches.length} newly added watchlist ${newMatches.length === 1 ? "item" : "items"} before the next review cycle.`); + } + + const activeCritical = items.filter((item) => item.kev || item.severity === "CRITICAL"); + if (activeCritical.length > 0) { + actions.push(`Reconfirm ownership and remediation status for ${activeCritical.length} critical or known-exploited watchlist ${activeCritical.length === 1 ? "item" : "items"}.`); + } + + if (clusters.length > 0) { + actions.push("Review clustered items together so duplicate investigation work and fragmented notes do not accumulate."); + } + + if (changedItems.length > 0) { + actions.push("Check the entries with triage or project updates to verify the recent workflow changes still match current exposure."); + } + + if (actions.length === 0) { + actions.push("No major deltas were detected; keep the watchlist stable and revisit when new items or workflow changes arrive."); + } + + return actions.slice(0, 5); +} + function buildTriageSignals(input: { severity: ReturnType; severityScore?: number; @@ -1273,6 +1452,47 @@ function sanitizeRemediationPlan(value: unknown): AIRemediationPlan { }; } +function sanitizeWatchlistReview(value: unknown): AIWatchlistReview { + const fallback = buildHeuristicWatchlistReview({ items: [], previousReviewAt: null }); + + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + headline: typeof record.headline === "string" ? record.headline : fallback.headline, + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + newMatches: Array.isArray(record.newMatches) + ? record.newMatches.filter((item): item is string => typeof item === "string").slice(0, 8) + : fallback.newMatches, + changedSinceLastReview: Array.isArray(record.changedSinceLastReview) + ? record.changedSinceLastReview.filter((item): item is string => typeof item === "string").slice(0, 10) + : fallback.changedSinceLastReview, + clusters: Array.isArray(record.clusters) + ? record.clusters + .filter((item): item is Record => Boolean(item) && typeof item === "object") + .flatMap((item) => { + const label = item.label; + const cveIds = item.cveIds; + const summary = item.summary; + return typeof label === "string" && Array.isArray(cveIds) && typeof summary === "string" + ? [{ + label, + cveIds: cveIds.filter((entry): entry is string => typeof entry === "string").slice(0, 8), + summary, + }] + : []; + }) + : fallback.clusters, + recommendedActions: Array.isArray(record.recommendedActions) + ? record.recommendedActions.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.recommendedActions, + previousReviewAt: typeof record.previousReviewAt === "string" ? record.previousReviewAt : fallback.previousReviewAt, + reviewedAt: typeof record.reviewedAt === "string" ? record.reviewedAt : fallback.reviewedAt, + }; +} + function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICveInsight["triage"] { if (!value || typeof value !== "object") return fallback; const record = value as Record; diff --git a/src/lib/ai-tool-registry.ts b/src/lib/ai-tool-registry.ts index 58527c8..303cd6f 100644 --- a/src/lib/ai-tool-registry.ts +++ b/src/lib/ai-tool-registry.ts @@ -12,19 +12,19 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "search_cves", description: "Search CVE summaries with structured filters and prioritization.", access: "read", - features: ["search_assistant", "daily_digest"], + features: ["search_assistant", "daily_digest", "watchlist_analyst"], }, { name: "fetch_cve_details", description: "Fetch detailed CVE records, severity data, references, and exploit context.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"], }, { name: "read_watchlist_state", description: "Read workspace watchlist records and tracked CVE identifiers.", access: "read", - features: ["daily_digest"], + features: ["daily_digest", "watchlist_analyst"], }, { name: "read_alert_rule_matches", @@ -36,7 +36,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "read_project_records", description: "Read project groupings, audit history, and linked CVE items.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"], }, { name: "write_project_records", @@ -48,7 +48,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "read_triage_state", description: "Read user-scoped triage notes, ownership, tags, and workflow status.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"], }, { name: "write_triage_state", diff --git a/src/lib/ai.ts b/src/lib/ai.ts index b990d27..c4e61c9 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -5,11 +5,13 @@ export { buildHeuristicDigest, buildHeuristicRemediationPlan, buildHeuristicTriageSuggestion, + buildHeuristicWatchlistReview, generateCveInsight, generateDigest, generateRemediationPlan, generateSearchInterpretation, generateTriageSuggestion, + generateWatchlistReview, getRecentAIRuns, getServerAIConfigurationSummary, interpretSearchPromptHeuristically, @@ -21,6 +23,7 @@ export type { RemediationPlanInput, ServerAIConfigurationSummary, TriageSuggestionInput, + WatchlistReviewInput, } from "./ai-service"; const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 2516ca4..0d263b9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -292,6 +292,23 @@ export interface AIRemediationPlan { requiresHumanApproval: boolean; } +export interface AIWatchlistReviewCluster { + label: string; + cveIds: string[]; + summary: string; +} + +export interface AIWatchlistReview { + headline: string; + summary: string; + newMatches: string[]; + changedSinceLastReview: string[]; + clusters: AIWatchlistReviewCluster[]; + recommendedActions: string[]; + previousReviewAt: string | null; + reviewedAt: string; +} + export type AISearchFilterField = "query" | "vendor" | "product" | "cwe" | "since" | "minSeverity" | "sort"; export interface AISearchAppliedFilter { @@ -351,7 +368,7 @@ export interface AIRunRecord { export type AIProvider = "heuristic" | "openai" | "anthropic"; -export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent"; +export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent" | "watchlist_analyst"; export interface AISettings { provider: AIProvider; diff --git a/src/lib/workspace-store.ts b/src/lib/workspace-store.ts index a0d5d4f..b27eb4a 100644 --- a/src/lib/workspace-store.ts +++ b/src/lib/workspace-store.ts @@ -19,6 +19,15 @@ export async function listWatchlist(userId: string): Promise { return rows.map((row) => row.cveId); } +export async function listWatchlistEntriesForUser(userId: string): Promise> { + return getDb().prepare(` + SELECT cve_id as cveId, added_at as addedAt + FROM user_watchlist + WHERE user_id = ? + ORDER BY added_at DESC + `).all(userId) as Array<{ cveId: string; addedAt: string }>; +} + export async function toggleWatchlistEntry(userId: string, cveId: string): Promise { withTransaction((db) => { const existing = db.prepare(` diff --git a/tests/ai.test.ts b/tests/ai.test.ts index fa20ee5..c43eb3f 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -5,6 +5,7 @@ import { buildHeuristicDigest, buildHeuristicRemediationPlan, buildHeuristicTriageSuggestion, + buildHeuristicWatchlistReview, getServerAIConfigurationSummary, interpretSearchPromptHeuristically, } from "../src/lib/ai"; @@ -194,6 +195,52 @@ test("buildHeuristicRemediationPlan drafts controls, validation, and rollout not assert.equal(result.recommendedOwner, "platform-security"); }); +test("buildHeuristicWatchlistReview highlights changes and clusters", () => { + const result = buildHeuristicWatchlistReview({ + previousReviewAt: "2026-03-07T08:00:00.000Z", + items: [ + { + id: "CVE-2026-5001", + summary: "Critical issue in edge gateway", + severity: "CRITICAL", + kev: true, + addedAt: "2026-03-07T09:00:00.000Z", + triageStatus: "new", + triageUpdatedAt: "2026-03-07T09:10:00.000Z", + projectNames: ["Edge Gateway"], + projectUpdatedAt: "2026-03-07T09:20:00.000Z", + aliases: [], + relatedIds: ["GHSA-edge-1"], + affectedProducts: ["edge-gateway"], + published: "2026-03-07T07:00:00.000Z", + modified: "2026-03-07T09:30:00.000Z", + }, + { + id: "CVE-2026-5002", + summary: "High issue in edge gateway plugin", + severity: "HIGH", + kev: false, + addedAt: "2026-03-06T07:00:00.000Z", + triageStatus: "investigating", + triageUpdatedAt: "2026-03-07T08:30:00.000Z", + projectNames: ["Edge Gateway"], + projectUpdatedAt: "2026-03-07T08:45:00.000Z", + aliases: [], + relatedIds: ["GHSA-edge-1"], + affectedProducts: ["edge-gateway"], + published: "2026-03-05T07:00:00.000Z", + modified: "2026-03-07T08:50:00.000Z", + }, + ], + }); + + assert.match(result.headline, /change|active/i); + assert.equal(result.newMatches.some((item) => item.includes("CVE-2026-5001")), true); + assert.equal(result.changedSinceLastReview.length >= 2, true); + assert.equal(result.clusters.length >= 1, true); + assert.equal(result.recommendedActions.length >= 1, true); +}); + test("getServerAIConfigurationSummary applies per-feature provider and model overrides", () => { const previous = { AI_PROVIDER: process.env.AI_PROVIDER, @@ -211,6 +258,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove AI_TRIAGE_AGENT_MODEL: process.env.AI_TRIAGE_AGENT_MODEL, AI_REMEDIATION_AGENT_PROVIDER: process.env.AI_REMEDIATION_AGENT_PROVIDER, AI_REMEDIATION_AGENT_MODEL: process.env.AI_REMEDIATION_AGENT_MODEL, + AI_WATCHLIST_ANALYST_PROVIDER: process.env.AI_WATCHLIST_ANALYST_PROVIDER, + AI_WATCHLIST_ANALYST_MODEL: process.env.AI_WATCHLIST_ANALYST_MODEL, }; process.env.AI_PROVIDER = "openai"; @@ -228,6 +277,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove process.env.AI_TRIAGE_AGENT_MODEL = "claude-triage"; process.env.AI_REMEDIATION_AGENT_PROVIDER = "openai"; process.env.AI_REMEDIATION_AGENT_MODEL = "gpt-remediation"; + process.env.AI_WATCHLIST_ANALYST_PROVIDER = "anthropic"; + process.env.AI_WATCHLIST_ANALYST_MODEL = "claude-watchlist"; try { const summary = getServerAIConfigurationSummary(); @@ -236,6 +287,7 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove const digest = summary.featureConfigurations.find((item) => item.feature === "daily_digest"); const triageAgent = summary.featureConfigurations.find((item) => item.feature === "triage_agent"); const remediationAgent = summary.featureConfigurations.find((item) => item.feature === "remediation_agent"); + const watchlistAnalyst = summary.featureConfigurations.find((item) => item.feature === "watchlist_analyst"); assert.equal(summary.provider, "openai"); assert.equal(summary.model, "gpt-global"); @@ -250,6 +302,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove assert.equal(triageAgent?.model, "claude-triage"); assert.equal(remediationAgent?.provider, "openai"); assert.equal(remediationAgent?.model, "gpt-remediation"); + assert.equal(watchlistAnalyst?.provider, "anthropic"); + assert.equal(watchlistAnalyst?.model, "claude-watchlist"); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) { From 89287bc4e693794f0d36b0927aa1903109ff2bc5 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:43:15 +0100 Subject: [PATCH 16/30] feat(ai): add project summary views --- src/app/api/ai/project/[id]/route.ts | 67 +++++++++++ src/components/AIProjectSummaryPanel.tsx | 105 +++++++++++++++++ src/components/AISettingsPageClient.tsx | 2 +- src/components/ProjectsPageClient.tsx | 3 + src/lib/ai-prompts.ts | 16 +++ src/lib/ai-runs-store.ts | 2 +- src/lib/ai-service.ts | 139 ++++++++++++++++++++++- src/lib/ai-tool-registry.ts | 6 +- src/lib/ai.ts | 3 + src/lib/projects-store.ts | 4 + src/lib/types.ts | 23 +++- tests/ai.test.ts | 54 +++++++++ 12 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 src/app/api/ai/project/[id]/route.ts create mode 100644 src/components/AIProjectSummaryPanel.tsx diff --git a/src/app/api/ai/project/[id]/route.ts b/src/app/api/ai/project/[id]/route.ts new file mode 100644 index 0000000..16a56a3 --- /dev/null +++ b/src/app/api/ai/project/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateProjectSummary } from "@/lib/ai-service"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { getProjectById } from "@/lib/projects-store"; +import { getCVEByIdServer } from "@/lib/server-api"; +import { readTriageMapForUser } from "@/lib/workspace-store"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { extractDescription, extractCVEId, getSeverityFromScore } from "@/lib/utils"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const project = await getProjectById(id); + + if (!project) { + return applyWorkspaceSession(NextResponse.json({ error: "Project not found" }, { status: 404 }), session); + } + + const triageMap = await readTriageMapForUser(session.userId); + const details = await Promise.all( + project.items.map(async (item) => { + try { + return await getCVEByIdServer(item.cveId); + } catch { + return null; + } + }) + ); + + const summary = await generateProjectSummary({ + project: { + id: project.id, + name: project.name, + description: project.description, + updatedAt: project.updatedAt, + items: project.items, + activity: project.activity, + }, + items: details.flatMap((detail) => { + if (!detail) { + return []; + } + + const cveId = extractCVEId(detail); + const triage = triageMap[cveId]; + const affectedProducts = detail.containers?.cna?.affected + ?.flatMap((item) => [item.product, item.vendor].filter((value): value is string => Boolean(value))) ?? []; + + return [{ + id: cveId, + summary: extractDescription(detail), + severity: getSeverityFromScore(detail.cvss3 ?? detail.cvss), + kev: Boolean(detail.kev), + triageStatus: triage?.status ?? "new", + owner: triage?.owner ?? "", + affectedProducts: Array.from(new Set(affectedProducts)).slice(0, 6), + published: detail.cveMetadata?.datePublished || detail.published || "", + }]; + }), + }); + + return applyWorkspaceSession(NextResponse.json(summary), session); +}, { + route: "/api/ai/project/[id]", + errorMessage: "Failed to generate AI project summary", + rateLimit: API_RATE_LIMITS.aiWrite, +}); diff --git a/src/components/AIProjectSummaryPanel.tsx b/src/components/AIProjectSummaryPanel.tsx new file mode 100644 index 0000000..a246aac --- /dev/null +++ b/src/components/AIProjectSummaryPanel.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { AIProjectSummary } from "@/lib/types"; + +export default function AIProjectSummaryPanel({ projectId }: { projectId: string }) { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState<"executive" | "analyst" | "engineering">("executive"); + + async function handleLoad() { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/ai/project/${encodeURIComponent(projectId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI project summary"); + } + + setSummary(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load AI project summary"); + } finally { + setLoading(false); + } + } + + const active = summary ? summary[view] : null; + + return ( +
+
+
+

AI Project Summary

+

Turns project state into executive, analyst, and engineering views without modifying the project.

+
+ +
+ + {error ?

{error}

: null} + + {summary ? ( +
+

{summary.overview}

+ +
+ + + + + +
+ +
+ {(["executive", "analyst", "engineering"] as const).map((option) => ( + + ))} +
+ + {active ? ( +
+

{active.headline}

+

{active.summary}

+
    + {active.bullets.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label}: {value} + + ); +} diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 8a5c788..5d18f38 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -37,7 +37,7 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: - Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_PROJECT_SUMMARY_PROVIDER`, `AI_PROJECT_SUMMARY_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. diff --git a/src/components/ProjectsPageClient.tsx b/src/components/ProjectsPageClient.tsx index d0a1b55..6a54697 100644 --- a/src/components/ProjectsPageClient.tsx +++ b/src/components/ProjectsPageClient.tsx @@ -6,6 +6,7 @@ import { createProjectAPI, deleteProjectAPI, listProjectsAPI, removeProjectItemA import { getCVEById } from "@/lib/api"; import { ProjectRecord, CVESummary } from "@/lib/types"; import CVEList from "./CVEList"; +import AIProjectSummaryPanel from "./AIProjectSummaryPanel"; type ProjectDetails = Record; @@ -217,6 +218,8 @@ export default function ProjectsPageClient() {
)} + + , + project_summary: { + feature: "project_summary", + version: "2026-03-07.project-summary.v1", + description: "Turn project state into executive, analyst, and engineering summaries.", + build: (input: unknown) => [ + "You are a project summary assistant for vulnerability response workflows.", + "Return only valid JSON matching this shape:", + '{"projectName":"string","overview":"string","executive":{"headline":"string","summary":"string","bullets":["string"]},"analyst":{"headline":"string","summary":"string","bullets":["string"]},"engineering":{"headline":"string","summary":"string","bullets":["string"]},"metrics":{"totalItems":0,"criticalCount":0,"highCount":0,"kevCount":0,"investigatingCount":0}}', + "Base your answer only on this input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, triage_agent: { feature: "triage_agent", version: "2026-03-07.triage.v1", @@ -114,6 +126,10 @@ export function getWatchlistAnalystPromptTemplate() { return AI_PROMPT_TEMPLATES.watchlist_analyst; } +export function getProjectSummaryPromptTemplate() { + return AI_PROMPT_TEMPLATES.project_summary; +} + export function getTriageAgentPromptTemplate() { return AI_PROMPT_TEMPLATES.triage_agent; } diff --git a/src/lib/ai-runs-store.ts b/src/lib/ai-runs-store.ts index 97af85d..eedb92f 100644 --- a/src/lib/ai-runs-store.ts +++ b/src/lib/ai-runs-store.ts @@ -136,7 +136,7 @@ function normalizeLimit(limit: number): number { } function isAIFeature(value: string): value is AIFeature { - return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"].includes(value); + return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"].includes(value); } function isAIProvider(value: string): value is AIProvider { diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index 054019d..33c2a16 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -2,6 +2,7 @@ import { AICveInsight, AIDigest, AIFeature, + AIProjectSummary, AIRemediationPlan, AIRunRecord, AITriageContextSnapshot, @@ -25,6 +26,7 @@ import { extractCVEId, extractDescription, getSeverityFromScore } from "./utils" import { getCveInsightPromptTemplate, getDailyDigestPromptTemplate, + getProjectSummaryPromptTemplate, getRemediationAgentPromptTemplate, getSearchAssistantPromptTemplate, getTriageAgentPromptTemplate, @@ -39,7 +41,7 @@ const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; const SEARCH_DEFAULT_SORT: SearchSortOption = "published_desc"; const SEARCH_DEFAULT_MIN_SEVERITY: SearchSeverityFilter = "ANY"; -const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"]; +const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"]; const AI_FEATURE_ENV_SEGMENTS: Record = { search_assistant: "SEARCH_ASSISTANT", cve_insight: "CVE_INSIGHT", @@ -47,6 +49,7 @@ const AI_FEATURE_ENV_SEGMENTS: Record = { triage_agent: "TRIAGE_AGENT", remediation_agent: "REMEDIATION_AGENT", watchlist_analyst: "WATCHLIST_ANALYST", + project_summary: "PROJECT_SUMMARY", }; export interface DigestInput { @@ -85,6 +88,20 @@ export interface WatchlistReviewInput { previousReviewAt: string | null; } +export interface ProjectSummaryInput { + project: Pick; + items: Array<{ + id: string; + summary: string; + severity: SearchSeverityFilter | "NONE" | "UNKNOWN"; + kev: boolean; + triageStatus: AITriageContextSnapshot["status"]; + owner: string; + affectedProducts: string[]; + published: string; + }>; +} + export interface ServerAIConfigurationSummary { provider: AIProvider; model: string; @@ -249,6 +266,17 @@ export async function generateWatchlistReview(input: WatchlistReviewInput): Prom }); } +export async function generateProjectSummary(input: ProjectSummaryInput): Promise { + const promptTemplate = getProjectSummaryPromptTemplate(); + return executeStructuredTask({ + feature: "project_summary", + prompt: promptTemplate.build(input), + fallback: () => buildHeuristicProjectSummary(input), + sanitize: sanitizeProjectSummary, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], + }); +} + export async function generateSearchInterpretation(prompt: string): Promise { const plan = runSearchPlanning(prompt); const heuristic = buildSearchInterpretationFromPlan(prompt, plan); @@ -493,6 +521,64 @@ export function buildHeuristicWatchlistReview(input: WatchlistReviewInput): AIWa }; } +export function buildHeuristicProjectSummary(input: ProjectSummaryInput): AIProjectSummary { + const criticalCount = input.items.filter((item) => item.severity === "CRITICAL").length; + const highCount = input.items.filter((item) => item.severity === "HIGH").length; + const kevCount = input.items.filter((item) => item.kev).length; + const investigatingCount = input.items.filter((item) => item.triageStatus === "investigating").length; + const topProducts = Array.from(new Set(input.items.flatMap((item) => item.affectedProducts))).slice(0, 3); + const owners = Array.from(new Set(input.items.map((item) => item.owner).filter(Boolean))).slice(0, 4); + const newest = [...input.items] + .sort((left, right) => (right.published || "").localeCompare(left.published || "")) + .slice(0, 3) + .map((item) => item.id); + + return { + projectName: input.project.name, + overview: `${input.project.name} tracks ${input.items.length} ${input.items.length === 1 ? "vulnerability" : "vulnerabilities"}${topProducts.length > 0 ? ` across ${topProducts.join(", ")}` : ""}.`, + executive: { + headline: criticalCount > 0 || kevCount > 0 ? "Immediate leadership attention recommended" : "Project risk is active but bounded", + summary: criticalCount > 0 || kevCount > 0 + ? `${input.project.name} includes ${criticalCount} critical ${criticalCount === 1 ? "issue" : "issues"} and ${kevCount} known-exploited ${kevCount === 1 ? "entry" : "entries"}, so remediation urgency and ownership should stay visible.` + : `${input.project.name} currently centers on ${highCount} high-severity ${highCount === 1 ? "item" : "items"} with ongoing analyst follow-up.`, + bullets: [ + `${input.items.length} total tracked ${input.items.length === 1 ? "item" : "items"} in the project workspace.`, + investigatingCount > 0 ? `${investigatingCount} ${investigatingCount === 1 ? "item is" : "items are"} actively investigating.` : "No items are currently marked as investigating.", + owners.length > 0 ? `Current ownership spans ${owners.join(", ")}.` : "Ownership is still loosely defined across the project.", + ], + }, + analyst: { + headline: "Analyst queue and workflow focus", + summary: newest.length > 0 + ? `Start with ${newest.join(", ")} and confirm whether triage state, project notes, and remediation plans still match current exposure.` + : "Review triage state and confirm the project still reflects the right incident grouping.", + bullets: [ + criticalCount > 0 ? `Prioritize the ${criticalCount} critical ${criticalCount === 1 ? "entry" : "entries"} for coordination and status checks.` : "No critical entries are present in this project right now.", + kevCount > 0 ? `Reconfirm exploit exposure and mitigation posture for ${kevCount} known-exploited ${kevCount === 1 ? "item" : "items"}.` : "No KEV-linked items are currently in the project.", + input.project.activity.length > 0 ? `Recent project activity suggests active coordination since ${new Date(input.project.activity[0].createdAt).toLocaleString("en-US")}.` : "Project activity history is still minimal.", + ], + }, + engineering: { + headline: "Engineering rollout view", + summary: topProducts.length > 0 + ? `Plan remediation by affected product area: ${topProducts.join(", ")}, and keep rollout notes aligned with existing project tracking.` + : "Use the project list to align engineering rollout sequencing with current remediation ownership.", + bullets: [ + owners.length > 0 ? `Coordinate implementation with ${owners.join(", ")}.` : "Assign a concrete engineering owner before scheduling rollout work.", + highCount > 0 || criticalCount > 0 ? "Bundle high-severity and critical changes into a prioritized rollout path with validation checkpoints." : "Treat remaining work as normal-priority remediation unless exposure changes.", + newest.length > 0 ? `Validate the newest impacted entries first: ${newest.join(", ")}.` : "Keep version validation and deployment checks attached to each project item.", + ], + }, + metrics: { + totalItems: input.items.length, + criticalCount, + highCount, + kevCount, + investigatingCount, + }, + }; +} + function normalizeCveInsightInput(input: CVEDetail | CveInsightInput): CveInsightInput { if ("detail" in input) { return input; @@ -1493,6 +1579,57 @@ function sanitizeWatchlistReview(value: unknown): AIWatchlistReview { }; } +function sanitizeProjectSummary(value: unknown): AIProjectSummary { + const fallback = buildHeuristicProjectSummary({ + project: { id: "project-unknown", name: "Unknown Project", description: "", updatedAt: "", items: [], activity: [] }, + items: [], + }); + + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + projectName: typeof record.projectName === "string" ? record.projectName : fallback.projectName, + overview: typeof record.overview === "string" ? record.overview : fallback.overview, + executive: sanitizeProjectSummarySection(record.executive, fallback.executive), + analyst: sanitizeProjectSummarySection(record.analyst, fallback.analyst), + engineering: sanitizeProjectSummarySection(record.engineering, fallback.engineering), + metrics: sanitizeProjectSummaryMetrics(record.metrics, fallback.metrics), + }; +} + +function sanitizeProjectSummarySection(value: unknown, fallback: AIProjectSummary["executive"]): AIProjectSummary["executive"] { + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + headline: typeof record.headline === "string" ? record.headline : fallback.headline, + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + bullets: Array.isArray(record.bullets) + ? record.bullets.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.bullets, + }; +} + +function sanitizeProjectSummaryMetrics(value: unknown, fallback: AIProjectSummary["metrics"]): AIProjectSummary["metrics"] { + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + totalItems: typeof record.totalItems === "number" ? record.totalItems : fallback.totalItems, + criticalCount: typeof record.criticalCount === "number" ? record.criticalCount : fallback.criticalCount, + highCount: typeof record.highCount === "number" ? record.highCount : fallback.highCount, + kevCount: typeof record.kevCount === "number" ? record.kevCount : fallback.kevCount, + investigatingCount: typeof record.investigatingCount === "number" ? record.investigatingCount : fallback.investigatingCount, + }; +} + function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICveInsight["triage"] { if (!value || typeof value !== "object") return fallback; const record = value as Record; diff --git a/src/lib/ai-tool-registry.ts b/src/lib/ai-tool-registry.ts index 303cd6f..a321e1d 100644 --- a/src/lib/ai-tool-registry.ts +++ b/src/lib/ai-tool-registry.ts @@ -18,7 +18,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "fetch_cve_details", description: "Fetch detailed CVE records, severity data, references, and exploit context.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"], }, { name: "read_watchlist_state", @@ -36,7 +36,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "read_project_records", description: "Read project groupings, audit history, and linked CVE items.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"], }, { name: "write_project_records", @@ -48,7 +48,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "read_triage_state", description: "Read user-scoped triage notes, ownership, tags, and workflow status.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"], }, { name: "write_triage_state", diff --git a/src/lib/ai.ts b/src/lib/ai.ts index c4e61c9..a5a29c1 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -3,11 +3,13 @@ import { AISettings } from "./types"; export { buildHeuristicCveInsight, buildHeuristicDigest, + buildHeuristicProjectSummary, buildHeuristicRemediationPlan, buildHeuristicTriageSuggestion, buildHeuristicWatchlistReview, generateCveInsight, generateDigest, + generateProjectSummary, generateRemediationPlan, generateSearchInterpretation, generateTriageSuggestion, @@ -20,6 +22,7 @@ export { export type { CveInsightInput, DigestInput, + ProjectSummaryInput, RemediationPlanInput, ServerAIConfigurationSummary, TriageSuggestionInput, diff --git a/src/lib/projects-store.ts b/src/lib/projects-store.ts index 9914d42..0c104be 100644 --- a/src/lib/projects-store.ts +++ b/src/lib/projects-store.ts @@ -50,6 +50,10 @@ export async function deleteProject(projectId: string): Promise { return result.changes > 0; } +export async function getProjectById(projectId: string): Promise { + return getProjectRecord(projectId); +} + export async function addProjectItem(projectId: string, item: { cveId: string; note?: string }): Promise { return withTransaction((db) => { const project = getProjectRecord(projectId, db); diff --git a/src/lib/types.ts b/src/lib/types.ts index 0d263b9..aed333d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -309,6 +309,27 @@ export interface AIWatchlistReview { reviewedAt: string; } +export interface AIProjectSummarySection { + headline: string; + summary: string; + bullets: string[]; +} + +export interface AIProjectSummary { + projectName: string; + overview: string; + executive: AIProjectSummarySection; + analyst: AIProjectSummarySection; + engineering: AIProjectSummarySection; + metrics: { + totalItems: number; + criticalCount: number; + highCount: number; + kevCount: number; + investigatingCount: number; + }; +} + export type AISearchFilterField = "query" | "vendor" | "product" | "cwe" | "since" | "minSeverity" | "sort"; export interface AISearchAppliedFilter { @@ -368,7 +389,7 @@ export interface AIRunRecord { export type AIProvider = "heuristic" | "openai" | "anthropic"; -export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent" | "watchlist_analyst"; +export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent" | "watchlist_analyst" | "project_summary"; export interface AISettings { provider: AIProvider; diff --git a/tests/ai.test.ts b/tests/ai.test.ts index c43eb3f..ce03fec 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildHeuristicCveInsight, buildHeuristicDigest, + buildHeuristicProjectSummary, buildHeuristicRemediationPlan, buildHeuristicTriageSuggestion, buildHeuristicWatchlistReview, @@ -241,6 +242,52 @@ test("buildHeuristicWatchlistReview highlights changes and clusters", () => { assert.equal(result.recommendedActions.length >= 1, true); }); +test("buildHeuristicProjectSummary returns executive analyst and engineering views", () => { + const result = buildHeuristicProjectSummary({ + project: { + id: "project-1", + name: "Edge Response", + description: "", + updatedAt: "2026-03-07T10:00:00.000Z", + items: [ + { cveId: "CVE-2026-6001", addedAt: "2026-03-07T09:00:00.000Z" }, + { cveId: "CVE-2026-6002", addedAt: "2026-03-07T09:05:00.000Z" }, + ], + activity: [{ id: "1", action: "project_item_added", summary: "Added CVEs", createdAt: "2026-03-07T10:30:00.000Z" }], + }, + items: [ + { + id: "CVE-2026-6001", + summary: "Critical edge issue", + severity: "CRITICAL", + kev: true, + triageStatus: "investigating", + owner: "edge-platform", + affectedProducts: ["edge-gateway"], + published: "2026-03-07T09:00:00.000Z", + }, + { + id: "CVE-2026-6002", + summary: "High plugin issue", + severity: "HIGH", + kev: false, + triageStatus: "new", + owner: "runtime-team", + affectedProducts: ["edge-plugin"], + published: "2026-03-07T08:00:00.000Z", + }, + ], + }); + + assert.equal(result.projectName, "Edge Response"); + assert.equal(result.metrics.totalItems, 2); + assert.equal(result.metrics.criticalCount, 1); + assert.equal(result.metrics.kevCount, 1); + assert.equal(result.executive.bullets.length >= 1, true); + assert.equal(result.analyst.bullets.length >= 1, true); + assert.equal(result.engineering.bullets.length >= 1, true); +}); + test("getServerAIConfigurationSummary applies per-feature provider and model overrides", () => { const previous = { AI_PROVIDER: process.env.AI_PROVIDER, @@ -260,6 +307,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove AI_REMEDIATION_AGENT_MODEL: process.env.AI_REMEDIATION_AGENT_MODEL, AI_WATCHLIST_ANALYST_PROVIDER: process.env.AI_WATCHLIST_ANALYST_PROVIDER, AI_WATCHLIST_ANALYST_MODEL: process.env.AI_WATCHLIST_ANALYST_MODEL, + AI_PROJECT_SUMMARY_PROVIDER: process.env.AI_PROJECT_SUMMARY_PROVIDER, + AI_PROJECT_SUMMARY_MODEL: process.env.AI_PROJECT_SUMMARY_MODEL, }; process.env.AI_PROVIDER = "openai"; @@ -279,6 +328,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove process.env.AI_REMEDIATION_AGENT_MODEL = "gpt-remediation"; process.env.AI_WATCHLIST_ANALYST_PROVIDER = "anthropic"; process.env.AI_WATCHLIST_ANALYST_MODEL = "claude-watchlist"; + process.env.AI_PROJECT_SUMMARY_PROVIDER = "openai"; + process.env.AI_PROJECT_SUMMARY_MODEL = "gpt-project"; try { const summary = getServerAIConfigurationSummary(); @@ -288,6 +339,7 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove const triageAgent = summary.featureConfigurations.find((item) => item.feature === "triage_agent"); const remediationAgent = summary.featureConfigurations.find((item) => item.feature === "remediation_agent"); const watchlistAnalyst = summary.featureConfigurations.find((item) => item.feature === "watchlist_analyst"); + const projectSummary = summary.featureConfigurations.find((item) => item.feature === "project_summary"); assert.equal(summary.provider, "openai"); assert.equal(summary.model, "gpt-global"); @@ -304,6 +356,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove assert.equal(remediationAgent?.model, "gpt-remediation"); assert.equal(watchlistAnalyst?.provider, "anthropic"); assert.equal(watchlistAnalyst?.model, "claude-watchlist"); + assert.equal(projectSummary?.provider, "openai"); + assert.equal(projectSummary?.model, "gpt-project"); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) { From 8c45d2fcd8da9f57dedab0b181da169beee604f3 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:46:20 +0100 Subject: [PATCH 17/30] feat(ai): add approval checkpoints for triage writes --- src/components/AITriageAssistantPanel.tsx | 30 +++++++--- src/components/HumanApprovalCheckpoint.tsx | 66 ++++++++++++++++++++++ src/components/TriagePanel.tsx | 26 +++++++-- src/lib/approval-checkpoints.ts | 61 ++++++++++++++++++++ tests/triage.test.ts | 23 ++++++++ 5 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 src/components/HumanApprovalCheckpoint.tsx create mode 100644 src/lib/approval-checkpoints.ts diff --git a/src/components/AITriageAssistantPanel.tsx b/src/components/AITriageAssistantPanel.tsx index 9ec2c41..0a232e9 100644 --- a/src/components/AITriageAssistantPanel.tsx +++ b/src/components/AITriageAssistantPanel.tsx @@ -8,12 +8,12 @@ export default function AITriageAssistantPanel({ cveId, detail, record, - onApply, + onRequestApproval, }: { cveId: string; detail?: CVEDetail | null; record: TriageRecord; - onApply: (updater: (current: TriageRecord) => TriageRecord) => void; + onRequestApproval: (updater: (current: TriageRecord) => TriageRecord, label: string) => void; }) { const [suggestion, setSuggestion] = useState(null); const [loading, setLoading] = useState(true); @@ -78,7 +78,7 @@ export default function AITriageAssistantPanel({

AI Triage Agent

-

Read-only guidance built from severity, EPSS, references, KEV, project context, and the current triage record.

+

Read-only guidance built from severity, EPSS, references, KEV, project context, and the current triage record. Any write now goes through an explicit approval checkpoint.

{suggestion?.requiresHumanApproval ? ( @@ -115,24 +115,36 @@ export default function AITriageAssistantPanel({
+
diff --git a/src/components/HumanApprovalCheckpoint.tsx b/src/components/HumanApprovalCheckpoint.tsx new file mode 100644 index 0000000..5ed80de --- /dev/null +++ b/src/components/HumanApprovalCheckpoint.tsx @@ -0,0 +1,66 @@ +import { ApprovalCheckpoint } from "@/lib/approval-checkpoints"; + +export default function HumanApprovalCheckpoint({ + checkpoint, + onApprove, + onCancel, +}: { + checkpoint: ApprovalCheckpoint; + onApprove: (checkpoint: ApprovalCheckpoint) => void; + onCancel: () => void; +}) { + return ( +
+
+
+

Human Approval Checkpoint

+

{checkpoint.title}

+

Source: {checkpoint.source}

+
+ + Review required + +
+ +
+

Summary

+

{checkpoint.summary}

+
+ +
+ {checkpoint.changes.map((change) => ( +
+

{change.field}

+
+
+

Current

+

{change.currentValue}

+
+
+

Proposed

+

{change.proposedValue}

+
+
+
+ ))} +
+ +
+ + +
+
+ ); +} diff --git a/src/components/TriagePanel.tsx b/src/components/TriagePanel.tsx index 2264df3..4cd27a2 100644 --- a/src/components/TriagePanel.tsx +++ b/src/components/TriagePanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { ApprovalCheckpoint, buildTriageApprovalCheckpoint } from "@/lib/approval-checkpoints"; import { createDefaultTriageRecord, loadTriageRecord, @@ -12,10 +13,12 @@ import { } from "@/lib/triage"; import { CVEDetail } from "@/lib/types"; import AITriageAssistantPanel from "./AITriageAssistantPanel"; +import HumanApprovalCheckpoint from "./HumanApprovalCheckpoint"; export default function TriagePanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { const [record, setRecord] = useState(() => createDefaultTriageRecord(cveId)); const [tagInput, setTagInput] = useState(""); + const [pendingApproval, setPendingApproval] = useState | null>(null); useEffect(() => { const sync = async () => { @@ -36,10 +39,15 @@ export default function TriagePanel({ cveId, detail }: { cveId: string; detail?: }).then((saved) => setRecord(saved)); }; - const applySuggestion = (updater: (current: TriageRecord) => TriageRecord) => { - const next = updater(record); - persist(next); - setTagInput(next.tags.join(", ")); + const requestApproval = (updater: (current: TriageRecord) => TriageRecord, label: string) => { + const checkpoint = buildTriageApprovalCheckpoint(record, updater(record), label); + setPendingApproval(checkpoint); + }; + + const approveCheckpoint = (checkpoint: ApprovalCheckpoint) => { + persist(checkpoint.nextState); + setTagInput(checkpoint.nextState.tags.join(", ")); + setPendingApproval(null); }; return ( @@ -124,7 +132,15 @@ export default function TriagePanel({ cveId, detail }: { cveId: string; detail?:
)} - + + + {pendingApproval ? ( + setPendingApproval(null)} + /> + ) : null}
); } diff --git a/src/lib/approval-checkpoints.ts b/src/lib/approval-checkpoints.ts new file mode 100644 index 0000000..2245396 --- /dev/null +++ b/src/lib/approval-checkpoints.ts @@ -0,0 +1,61 @@ +import { summarizeTriageChanges, TriageRecord } from "./triage-shared"; + +export type ApprovalCheckpointScope = "triage_state" | "project_record" | "notification"; + +export interface ApprovalFieldChange { + field: string; + currentValue: string; + proposedValue: string; +} + +export interface ApprovalCheckpoint { + id: string; + scope: ApprovalCheckpointScope; + source: string; + title: string; + summary: string; + changes: ApprovalFieldChange[]; + nextState: TState; +} + +export function buildTriageApprovalCheckpoint( + previous: TriageRecord, + next: TriageRecord, + source = "AI triage agent" +): ApprovalCheckpoint | null { + const changes = summarizeTriageChanges(previous, next); + if (changes.length === 0) { + return null; + } + + return { + id: crypto.randomUUID(), + scope: "triage_state", + source, + title: `Approve triage update for ${previous.cveId}`, + summary: changes.join(" • "), + changes: [ + buildFieldChange("Status", previous.status, next.status), + buildFieldChange("Owner", previous.owner, next.owner), + buildFieldChange("Notes", previous.notes, next.notes), + buildFieldChange("Tags", previous.tags.join(", "), next.tags.join(", ")), + ].filter((value): value is ApprovalFieldChange => Boolean(value)), + nextState: next, + }; +} + +function buildFieldChange(field: string, currentValue: string, proposedValue: string): ApprovalFieldChange | null { + if (currentValue === proposedValue) { + return null; + } + + return { + field, + currentValue: normalizeValue(currentValue), + proposedValue: normalizeValue(proposedValue), + }; +} + +function normalizeValue(value: string): string { + return value.trim() ? value : "Not set"; +} diff --git a/tests/triage.test.ts b/tests/triage.test.ts index 7215cc6..c17c659 100644 --- a/tests/triage.test.ts +++ b/tests/triage.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { buildTriageApprovalCheckpoint } from "../src/lib/approval-checkpoints"; import { createDefaultTriageRecord, getTriageStatusLabel, parseTags, summarizeTriageChanges } from "../src/lib/triage"; test("parseTags normalizes comma-separated tag input", () => { @@ -38,3 +39,25 @@ test("summarizeTriageChanges describes meaningful field updates", () => { "Tags updated: internet-facing, patch-window", ]); }); + +test("buildTriageApprovalCheckpoint captures proposed triage updates", () => { + const previous = createDefaultTriageRecord("CVE-2026-0001"); + const next = { + ...previous, + status: "investigating" as const, + owner: "secops", + tags: ["internet-facing"], + }; + + const checkpoint = buildTriageApprovalCheckpoint(previous, next, "AI triage full recommendation"); + + assert.equal(checkpoint?.scope, "triage_state"); + assert.match(checkpoint?.summary || "", /Status changed|Owner set|Tags updated/); + assert.equal(checkpoint?.changes.some((change) => change.field === "Status" && change.proposedValue === "investigating"), true); + assert.equal(checkpoint?.changes.some((change) => change.field === "Owner" && change.proposedValue === "secops"), true); +}); + +test("buildTriageApprovalCheckpoint returns null when nothing changes", () => { + const previous = createDefaultTriageRecord("CVE-2026-0001"); + assert.equal(buildTriageApprovalCheckpoint(previous, previous), null); +}); From e62399f8b6bd94d12068a9b55c99c9a07576a5e0 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:50:15 +0100 Subject: [PATCH 18/30] fix(ai): redact sensitive prompt context --- src/app/api/ai/project/[id]/route.ts | 5 +- src/components/AISettingsPageClient.tsx | 8 ++ src/lib/ai-service.ts | 111 ++++++++++++++++++++++-- src/lib/ai.ts | 1 + tests/ai.test.ts | 51 +++++++++++ 5 files changed, 169 insertions(+), 7 deletions(-) diff --git a/src/app/api/ai/project/[id]/route.ts b/src/app/api/ai/project/[id]/route.ts index 16a56a3..e955a8a 100644 --- a/src/app/api/ai/project/[id]/route.ts +++ b/src/app/api/ai/project/[id]/route.ts @@ -59,7 +59,10 @@ export const POST = withRouteProtection(async function POST(request: NextRequest }), }); - return applyWorkspaceSession(NextResponse.json(summary), session); + return applyWorkspaceSession(NextResponse.json({ + ...summary, + projectName: project.name, + }), session); }, { route: "/api/ai/project/[id]", errorMessage: "Failed to generate AI project summary", diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 5d18f38..3ae137b 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -49,6 +49,14 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: + + + {summary.redactionEnabledForExternalModels + ? "Sensitive triage notes, owners, and project metadata are redacted before prompts are sent to third-party model providers. Set `AI_ALLOW_SENSITIVE_MODEL_DATA=true` only if you explicitly want to disable that safeguard." + : "Sensitive prompt redaction is disabled for external model calls because `AI_ALLOW_SENSITIVE_MODEL_DATA=true` is set."} + + + {summary.availableProviders.length > 0 diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index 33c2a16..f0f1b24 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -108,6 +108,8 @@ export interface ServerAIConfigurationSummary { mode: "heuristic" | "configured"; configured: boolean; availableProviders: AIProvider[]; + redactionEnabledForExternalModels: boolean; + sensitiveDataAllowedToModels: boolean; featureConfigurations: Array<{ feature: AIFeature; provider: AIProvider; @@ -202,6 +204,8 @@ export function getServerAIConfigurationSummary(): ServerAIConfigurationSummary mode: runtime.mode, configured: runtime.mode === "configured", availableProviders, + redactionEnabledForExternalModels: !isSensitiveModelDataAllowed(), + sensitiveDataAllowedToModels: isSensitiveModelDataAllowed(), promptTemplates: listPromptTemplates(), toolRegistry: listAITools(), featureConfigurations: AI_FEATURES.map((feature) => { @@ -222,11 +226,33 @@ export async function getRecentAIRuns(limit = 25): Promise { return listRecentAIRuns(limit); } +export function preparePromptInputForFeature(feature: AIFeature, input: T): T { + const runtime = resolveAIRuntime(feature); + if (!shouldRedactPromptInput(runtime)) { + return input; + } + + switch (feature) { + case "cve_insight": + case "triage_agent": + case "remediation_agent": + return redactCveInsightInput(input as CveInsightInput) as T; + case "daily_digest": + return redactDigestInput(input as DigestInput) as T; + case "watchlist_analyst": + return redactWatchlistReviewInput(input as WatchlistReviewInput) as T; + case "project_summary": + return redactProjectSummaryInput(input as ProjectSummaryInput) as T; + default: + return input; + } +} + export async function generateCveInsight(input: CveInsightInput): Promise { const promptTemplate = getCveInsightPromptTemplate(); return executeStructuredTask({ feature: "cve_insight", - prompt: promptTemplate.build(input), + prompt: promptTemplate.build(preparePromptInputForFeature("cve_insight", input)), fallback: () => buildHeuristicCveInsight(input), sanitize: sanitizeInsight, toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], @@ -237,7 +263,7 @@ export async function generateTriageSuggestion(input: TriageSuggestionInput): Pr const promptTemplate = getTriageAgentPromptTemplate(); return executeStructuredTask({ feature: "triage_agent", - prompt: promptTemplate.build(input), + prompt: promptTemplate.build(preparePromptInputForFeature("triage_agent", input)), fallback: () => buildHeuristicTriageSuggestion(input), sanitize: sanitizeTriageSuggestion, toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], @@ -248,7 +274,7 @@ export async function generateRemediationPlan(input: RemediationPlanInput): Prom const promptTemplate = getRemediationAgentPromptTemplate(); return executeStructuredTask({ feature: "remediation_agent", - prompt: promptTemplate.build(input), + prompt: promptTemplate.build(preparePromptInputForFeature("remediation_agent", input)), fallback: () => buildHeuristicRemediationPlan(input), sanitize: sanitizeRemediationPlan, toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], @@ -259,7 +285,7 @@ export async function generateWatchlistReview(input: WatchlistReviewInput): Prom const promptTemplate = getWatchlistAnalystPromptTemplate(); return executeStructuredTask({ feature: "watchlist_analyst", - prompt: promptTemplate.build(input), + prompt: promptTemplate.build(preparePromptInputForFeature("watchlist_analyst", input)), fallback: () => buildHeuristicWatchlistReview(input), sanitize: sanitizeWatchlistReview, toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], @@ -270,7 +296,7 @@ export async function generateProjectSummary(input: ProjectSummaryInput): Promis const promptTemplate = getProjectSummaryPromptTemplate(); return executeStructuredTask({ feature: "project_summary", - prompt: promptTemplate.build(input), + prompt: promptTemplate.build(preparePromptInputForFeature("project_summary", input)), fallback: () => buildHeuristicProjectSummary(input), sanitize: sanitizeProjectSummary, toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], @@ -341,7 +367,7 @@ export async function generateDigest(input: DigestInput): Promise { const promptTemplate = getDailyDigestPromptTemplate(); return executeStructuredTask({ feature: "daily_digest", - prompt: promptTemplate.build(input), + prompt: promptTemplate.build(preparePromptInputForFeature("daily_digest", input)), fallback: () => buildHeuristicDigest(input), sanitize: sanitizeDigest, toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], @@ -579,6 +605,79 @@ export function buildHeuristicProjectSummary(input: ProjectSummaryInput): AIProj }; } +function shouldRedactPromptInput(runtime: AIRuntimeSettings): boolean { + return runtime.provider !== "heuristic" && !isSensitiveModelDataAllowed(); +} + +function isSensitiveModelDataAllowed(): boolean { + return process.env.AI_ALLOW_SENSITIVE_MODEL_DATA === "true"; +} + +function redactCveInsightInput(input: CveInsightInput): CveInsightInput { + return { + ...input, + triage: input.triage + ? { + ...input.triage, + owner: redactSensitiveString(input.triage.owner, "[redacted owner]"), + notes: redactSensitiveString(input.triage.notes, "[redacted analyst notes]"), + } + : input.triage, + relatedProjects: input.relatedProjects.map((project, index) => ({ + name: maskProjectName(index), + updatedAt: project.updatedAt, + items: project.items.map((item) => ({ cveId: item.cveId, addedAt: item.addedAt })), + })), + }; +} + +function redactDigestInput(input: DigestInput): DigestInput { + return { + ...input, + projects: input.projects.map((project, index) => ({ + name: maskProjectName(index), + updatedAt: project.updatedAt, + items: project.items.map((item) => ({ cveId: item.cveId, addedAt: item.addedAt })), + })), + }; +} + +function redactWatchlistReviewInput(input: WatchlistReviewInput): WatchlistReviewInput { + return { + ...input, + items: input.items.map((item) => ({ + ...item, + projectNames: item.projectNames.map((_, index) => maskProjectName(index)), + })), + }; +} + +function redactProjectSummaryInput(input: ProjectSummaryInput): ProjectSummaryInput { + return { + project: { + ...input.project, + name: "[redacted project]", + description: redactSensitiveString(input.project.description, "[redacted project description]"), + activity: input.project.activity.map((entry, index) => ({ + ...entry, + summary: `[redacted activity ${index + 1}]`, + })), + }, + items: input.items.map((item) => ({ + ...item, + owner: redactSensitiveString(item.owner, "[redacted owner]"), + })), + }; +} + +function maskProjectName(index: number): string { + return `Tracked project ${index + 1}`; +} + +function redactSensitiveString(value: string, replacement: string): string { + return value.trim() ? replacement : value; +} + function normalizeCveInsightInput(input: CVEDetail | CveInsightInput): CveInsightInput { if ("detail" in input) { return input; diff --git a/src/lib/ai.ts b/src/lib/ai.ts index a5a29c1..ffb7bf4 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -17,6 +17,7 @@ export { getRecentAIRuns, getServerAIConfigurationSummary, interpretSearchPromptHeuristically, + preparePromptInputForFeature, } from "./ai-service"; export type { diff --git a/tests/ai.test.ts b/tests/ai.test.ts index ce03fec..27817e1 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -9,6 +9,7 @@ import { buildHeuristicWatchlistReview, getServerAIConfigurationSummary, interpretSearchPromptHeuristically, + preparePromptInputForFeature, } from "../src/lib/ai"; test("interpretSearchPromptHeuristically extracts severity and recent window", () => { @@ -288,6 +289,52 @@ test("buildHeuristicProjectSummary returns executive analyst and engineering vie assert.equal(result.engineering.bullets.length >= 1, true); }); +test("preparePromptInputForFeature redacts sensitive notes and project metadata for external providers", () => { + const previous = { + AI_PROVIDER: process.env.AI_PROVIDER, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + AI_ALLOW_SENSITIVE_MODEL_DATA: process.env.AI_ALLOW_SENSITIVE_MODEL_DATA, + }; + + process.env.AI_PROVIDER = "openai"; + process.env.OPENAI_API_KEY = "test-openai-key"; + delete process.env.AI_ALLOW_SENSITIVE_MODEL_DATA; + + try { + const redacted = preparePromptInputForFeature("triage_agent", { + detail: { id: "CVE-2026-7001" }, + epss: null, + triage: { + status: "investigating", + owner: "platform-security", + notes: "Customer-facing production edge cluster", + tags: ["internet-facing"], + updatedAt: "2026-03-07T12:00:00.000Z", + }, + relatedProjects: [ + { + name: "Top Secret Project", + updatedAt: "2026-03-07T12:00:00.000Z", + items: [{ cveId: "CVE-2026-7001", note: "Privileged note", addedAt: "2026-03-07T11:00:00.000Z" }], + }, + ], + }); + + assert.equal(redacted.triage?.owner, "[redacted owner]"); + assert.equal(redacted.triage?.notes, "[redacted analyst notes]"); + assert.equal(redacted.relatedProjects[0]?.name, "Tracked project 1"); + assert.equal("note" in (redacted.relatedProjects[0]?.items[0] ?? {}), false); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}); + test("getServerAIConfigurationSummary applies per-feature provider and model overrides", () => { const previous = { AI_PROVIDER: process.env.AI_PROVIDER, @@ -309,6 +356,7 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove AI_WATCHLIST_ANALYST_MODEL: process.env.AI_WATCHLIST_ANALYST_MODEL, AI_PROJECT_SUMMARY_PROVIDER: process.env.AI_PROJECT_SUMMARY_PROVIDER, AI_PROJECT_SUMMARY_MODEL: process.env.AI_PROJECT_SUMMARY_MODEL, + AI_ALLOW_SENSITIVE_MODEL_DATA: process.env.AI_ALLOW_SENSITIVE_MODEL_DATA, }; process.env.AI_PROVIDER = "openai"; @@ -330,6 +378,7 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove process.env.AI_WATCHLIST_ANALYST_MODEL = "claude-watchlist"; process.env.AI_PROJECT_SUMMARY_PROVIDER = "openai"; process.env.AI_PROJECT_SUMMARY_MODEL = "gpt-project"; + delete process.env.AI_ALLOW_SENSITIVE_MODEL_DATA; try { const summary = getServerAIConfigurationSummary(); @@ -343,6 +392,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove assert.equal(summary.provider, "openai"); assert.equal(summary.model, "gpt-global"); + assert.equal(summary.redactionEnabledForExternalModels, true); + assert.equal(summary.sensitiveDataAllowedToModels, false); assert.equal(search?.provider, "heuristic"); assert.equal(search?.mode, "heuristic"); assert.equal(search?.model, ""); From b6d6259a87d6a3c5b8d533412d23505e5e20fbf3 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:55:43 +0100 Subject: [PATCH 19/30] feat(ai): add alert investigation agent --- .../api/ai/alerts/investigate/[id]/route.ts | 64 +++++++++ src/components/AIAlertInvestigationPanel.tsx | 99 ++++++++++++++ src/components/AISettingsPageClient.tsx | 2 +- src/components/AlertsPageClient.tsx | 3 + src/lib/ai-prompts.ts | 16 +++ src/lib/ai-runs-store.ts | 2 +- src/lib/ai-service.ts | 124 +++++++++++++++++- src/lib/ai-tool-registry.ts | 8 +- src/lib/ai.ts | 3 + src/lib/types.ts | 18 ++- src/lib/workspace-store.ts | 5 + tests/ai.test.ts | 54 ++++++++ 12 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 src/app/api/ai/alerts/investigate/[id]/route.ts create mode 100644 src/components/AIAlertInvestigationPanel.tsx diff --git a/src/app/api/ai/alerts/investigate/[id]/route.ts b/src/app/api/ai/alerts/investigate/[id]/route.ts new file mode 100644 index 0000000..8c96cfb --- /dev/null +++ b/src/app/api/ai/alerts/investigate/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applySearchResultPreferences, matchesSearchState } from "@/lib/search"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { generateAlertInvestigation } from "@/lib/ai-service"; +import { getLatestCVEsServer } from "@/lib/server-api"; +import { getSeverityFromScore } from "@/lib/utils"; +import { getAlertRuleForUser } from "@/lib/workspace-store"; + +const ALERT_SAMPLE_SIZE = 80; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const rule = await getAlertRuleForUser(session.userId, id); + + if (!rule) { + return applyWorkspaceSession(NextResponse.json({ error: "Alert rule not found" }, { status: 404 }), session); + } + + const sample = await getLatestCVEsServer(1, ALERT_SAMPLE_SIZE).catch(() => []); + const matching = applySearchResultPreferences( + sample.filter((cve) => matchesSearchState(cve, rule.search)), + rule.search + ); + + const investigation = await generateAlertInvestigation({ + rule: { + id: rule.id, + name: rule.name, + lastCheckedAt: rule.lastCheckedAt, + search: rule.search, + }, + matches: matching.slice(0, 8).map((cve) => ({ + id: cve.id, + summary: cve.summary || cve.description || "No summary available", + severity: getSeverityFromScore(cve.cvss3 ?? cve.cvss), + kev: Boolean(cve.kev), + published: cve.published || "", + modified: cve.modified || cve.published || "", + unread: isUnreadMatch(cve.modified ?? cve.published ?? "", rule.lastCheckedAt), + })), + }); + + return applyWorkspaceSession(NextResponse.json(investigation), session); +}, { + route: "/api/ai/alerts/investigate/[id]", + errorMessage: "Failed to investigate alert rule with AI", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isUnreadMatch(modified: string, lastCheckedAt: string | null): boolean { + if (!lastCheckedAt) { + return true; + } + + const modifiedTs = Date.parse(modified); + const checkedTs = Date.parse(lastCheckedAt); + if (Number.isNaN(modifiedTs) || Number.isNaN(checkedTs)) { + return true; + } + + return modifiedTs > checkedTs; +} diff --git a/src/components/AIAlertInvestigationPanel.tsx b/src/components/AIAlertInvestigationPanel.tsx new file mode 100644 index 0000000..c31123c --- /dev/null +++ b/src/components/AIAlertInvestigationPanel.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { AIAlertInvestigation } from "@/lib/types"; + +export default function AIAlertInvestigationPanel({ ruleId }: { ruleId: string }) { + const [investigation, setInvestigation] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleLoad() { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/ai/alerts/investigate/${encodeURIComponent(ruleId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to investigate alert rule"); + } + + setInvestigation(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to investigate alert rule"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

AI Alert Investigation

+

Explains why this rule matched and suggests the next analyst action.

+
+ +
+ + {error ?

{error}

: null} + + {investigation ? ( +
+

{investigation.summary}

+ +
+ +
+

Top Matches

+
+ {investigation.topMatches.map((match) => ( +
+
+ {match.id} + {match.unread ? Unread : null} +
+

{match.summary}

+

{match.rationale}

+
+ ))} +
+
+ +
+

Recommended Action

+

{investigation.recommendedAction}

+
+ +
+
+ ) : null} +
+ ); +} + +function Section({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ); +} diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 3ae137b..8abba3d 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -37,7 +37,7 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: - Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_PROJECT_SUMMARY_PROVIDER`, `AI_PROJECT_SUMMARY_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_PROJECT_SUMMARY_PROVIDER`, `AI_PROJECT_SUMMARY_MODEL`, `AI_ALERT_INVESTIGATION_PROVIDER`, `AI_ALERT_INVESTIGATION_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. diff --git a/src/components/AlertsPageClient.tsx b/src/components/AlertsPageClient.tsx index b544bc5..db52c07 100644 --- a/src/components/AlertsPageClient.tsx +++ b/src/components/AlertsPageClient.tsx @@ -14,6 +14,7 @@ import { import { applySearchResultPreferences, matchesSearchState } from "@/lib/search"; import { CVESummary } from "@/lib/types"; import CVEList from "@/components/CVEList"; +import AIAlertInvestigationPanel from "@/components/AIAlertInvestigationPanel"; const ALERT_SAMPLE_SIZE = 80; @@ -194,6 +195,8 @@ export default function AlertsPageClient() {
+ + ))} diff --git a/src/lib/ai-prompts.ts b/src/lib/ai-prompts.ts index ed43193..283e3a7 100644 --- a/src/lib/ai-prompts.ts +++ b/src/lib/ai-prompts.ts @@ -76,6 +76,18 @@ export const AI_PROMPT_TEMPLATES = { JSON.stringify(input), ].join("\n"), } satisfies PromptTemplate, + alert_investigation: { + feature: "alert_investigation", + version: "2026-03-07.alert-investigation.v1", + description: "Explain why an alert rule matched and propose the next analyst action.", + build: (input: unknown) => [ + "You are an alert investigation assistant for vulnerability analysts.", + "Return only valid JSON matching this shape:", + '{"ruleName":"string","summary":"string","whyMatched":["string"],"topMatches":[{"id":"string","summary":"string","rationale":"string","unread":true}],"recommendedAction":"string","nextSteps":["string"]}', + "Base your answer only on this input JSON:", + JSON.stringify(input), + ].join("\n"), + } satisfies PromptTemplate, triage_agent: { feature: "triage_agent", version: "2026-03-07.triage.v1", @@ -130,6 +142,10 @@ export function getProjectSummaryPromptTemplate() { return AI_PROMPT_TEMPLATES.project_summary; } +export function getAlertInvestigationPromptTemplate() { + return AI_PROMPT_TEMPLATES.alert_investigation; +} + export function getTriageAgentPromptTemplate() { return AI_PROMPT_TEMPLATES.triage_agent; } diff --git a/src/lib/ai-runs-store.ts b/src/lib/ai-runs-store.ts index eedb92f..cab64de 100644 --- a/src/lib/ai-runs-store.ts +++ b/src/lib/ai-runs-store.ts @@ -136,7 +136,7 @@ function normalizeLimit(limit: number): number { } function isAIFeature(value: string): value is AIFeature { - return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"].includes(value); + return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary", "alert_investigation"].includes(value); } function isAIProvider(value: string): value is AIProvider { diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index f0f1b24..27bdc46 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -1,4 +1,5 @@ import { + AIAlertInvestigation, AICveInsight, AIDigest, AIFeature, @@ -24,6 +25,7 @@ import { appendAIRun, listRecentAIRuns } from "./ai-runs-store"; import { SearchState, normalizeSearchState } from "./search"; import { extractCVEId, extractDescription, getSeverityFromScore } from "./utils"; import { + getAlertInvestigationPromptTemplate, getCveInsightPromptTemplate, getDailyDigestPromptTemplate, getProjectSummaryPromptTemplate, @@ -41,7 +43,7 @@ const DEFAULT_OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini"; const DEFAULT_ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-3-5-haiku-latest"; const SEARCH_DEFAULT_SORT: SearchSortOption = "published_desc"; const SEARCH_DEFAULT_MIN_SEVERITY: SearchSeverityFilter = "ANY"; -const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"]; +const AI_FEATURES: AIFeature[] = ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary", "alert_investigation"]; const AI_FEATURE_ENV_SEGMENTS: Record = { search_assistant: "SEARCH_ASSISTANT", cve_insight: "CVE_INSIGHT", @@ -50,6 +52,7 @@ const AI_FEATURE_ENV_SEGMENTS: Record = { remediation_agent: "REMEDIATION_AGENT", watchlist_analyst: "WATCHLIST_ANALYST", project_summary: "PROJECT_SUMMARY", + alert_investigation: "ALERT_INVESTIGATION", }; export interface DigestInput { @@ -102,6 +105,24 @@ export interface ProjectSummaryInput { }>; } +export interface AlertInvestigationInput { + rule: { + id: string; + name: string; + lastCheckedAt: string | null; + search: SearchState; + }; + matches: Array<{ + id: string; + summary: string; + severity: SearchSeverityFilter | "NONE" | "UNKNOWN"; + kev: boolean; + published: string; + modified: string; + unread: boolean; + }>; +} + export interface ServerAIConfigurationSummary { provider: AIProvider; model: string; @@ -303,6 +324,17 @@ export async function generateProjectSummary(input: ProjectSummaryInput): Promis }); } +export async function generateAlertInvestigation(input: AlertInvestigationInput): Promise { + const promptTemplate = getAlertInvestigationPromptTemplate(); + return executeStructuredTask({ + feature: "alert_investigation", + prompt: promptTemplate.build(preparePromptInputForFeature("alert_investigation", input)), + fallback: () => buildHeuristicAlertInvestigation(input), + sanitize: sanitizeAlertInvestigation, + toolCalls: [{ tool: "prompt_template", summary: `${promptTemplate.feature}@${promptTemplate.version}` }], + }); +} + export async function generateSearchInterpretation(prompt: string): Promise { const plan = runSearchPlanning(prompt); const heuristic = buildSearchInterpretationFromPlan(prompt, plan); @@ -605,6 +637,50 @@ export function buildHeuristicProjectSummary(input: ProjectSummaryInput): AIProj }; } +export function buildHeuristicAlertInvestigation(input: AlertInvestigationInput): AIAlertInvestigation { + const unreadMatches = input.matches.filter((item) => item.unread); + const topMatches = input.matches.slice(0, 3); + const whyMatched = [ + input.rule.search.query ? `The rule keeps matches whose text or aliases include ${input.rule.search.query}.` : "The rule evaluates the saved search filters against the latest upstream CVE sample.", + input.rule.search.product ? `Product filtering is scoped to ${input.rule.search.product}.` : "The rule is not restricted to a single product filter.", + input.rule.search.minSeverity !== "ANY" ? `Only ${input.rule.search.minSeverity.toLowerCase()} or higher issues qualify for this rule.` : "Severity is not the only reason a match appears, because the rule allows any severity.", + ].filter(Boolean); + + return { + ruleName: input.rule.name, + summary: `${input.rule.name} matched ${input.matches.length} ${input.matches.length === 1 ? "entry" : "entries"}${unreadMatches.length > 0 ? `, including ${unreadMatches.length} unread ${unreadMatches.length === 1 ? "change" : "changes"}` : ""}.`, + whyMatched, + topMatches: topMatches.map((match) => ({ + id: match.id, + summary: match.summary, + unread: match.unread, + rationale: buildAlertMatchRationale(match), + })), + recommendedAction: unreadMatches.length > 0 + ? `Start with the ${unreadMatches.length} unread ${unreadMatches.length === 1 ? "match" : "matches"} and confirm whether the rule still needs escalation or ownership updates.` + : "Review the highest-risk matched CVEs, then mark the rule checked if the current set is already covered by triage or remediation work.", + nextSteps: [ + unreadMatches.length > 0 ? "Inspect unread matches first because they changed since the last time this rule was checked." : "Reconfirm whether the current matches are already reflected in triage or project tracking.", + topMatches.length > 0 ? `Validate the top matched CVEs: ${topMatches.map((item) => item.id).join(", ")}.` : "No top matches were available from the current sample.", + input.matches.some((item) => item.kev) ? "Escalate any known-exploited matches into the active analyst queue immediately." : "If no exploit-linked signals exist, keep follow-up proportional to severity and recency.", + ], + }; +} + +function buildAlertMatchRationale(match: AlertInvestigationInput["matches"][number]): string { + const parts = [`severity ${match.severity.toLowerCase()}`]; + + if (match.kev) { + parts.push("known exploited"); + } + + if (match.unread) { + parts.push("new since the last rule check"); + } + + return parts.join(" • "); +} + function shouldRedactPromptInput(runtime: AIRuntimeSettings): boolean { return runtime.provider !== "heuristic" && !isSensitiveModelDataAllowed(); } @@ -1729,6 +1805,52 @@ function sanitizeProjectSummaryMetrics(value: unknown, fallback: AIProjectSummar }; } +function sanitizeAlertInvestigation(value: unknown): AIAlertInvestigation { + const fallback = buildHeuristicAlertInvestigation({ + rule: { + id: "alert-unknown", + name: "Unknown alert", + lastCheckedAt: null, + search: normalizeSearchState({}), + }, + matches: [], + }); + + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + return { + ruleName: typeof record.ruleName === "string" ? record.ruleName : fallback.ruleName, + summary: typeof record.summary === "string" ? record.summary : fallback.summary, + whyMatched: Array.isArray(record.whyMatched) + ? record.whyMatched.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.whyMatched, + topMatches: Array.isArray(record.topMatches) + ? record.topMatches.flatMap((item) => { + if (!item || typeof item !== "object") { + return []; + } + + const match = item as Record; + return typeof match.id === "string" && typeof match.summary === "string" && typeof match.rationale === "string" + ? [{ + id: match.id, + summary: match.summary, + rationale: match.rationale, + unread: Boolean(match.unread), + }] + : []; + }).slice(0, 5) + : fallback.topMatches, + recommendedAction: typeof record.recommendedAction === "string" ? record.recommendedAction : fallback.recommendedAction, + nextSteps: Array.isArray(record.nextSteps) + ? record.nextSteps.filter((item): item is string => typeof item === "string").slice(0, 6) + : fallback.nextSteps, + }; +} + function sanitizeTriage(value: unknown, fallback: AICveInsight["triage"]): AICveInsight["triage"] { if (!value || typeof value !== "object") return fallback; const record = value as Record; diff --git a/src/lib/ai-tool-registry.ts b/src/lib/ai-tool-registry.ts index a321e1d..ffcdb93 100644 --- a/src/lib/ai-tool-registry.ts +++ b/src/lib/ai-tool-registry.ts @@ -12,13 +12,13 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "search_cves", description: "Search CVE summaries with structured filters and prioritization.", access: "read", - features: ["search_assistant", "daily_digest", "watchlist_analyst"], + features: ["search_assistant", "daily_digest", "watchlist_analyst", "alert_investigation"], }, { name: "fetch_cve_details", description: "Fetch detailed CVE records, severity data, references, and exploit context.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary", "alert_investigation"], }, { name: "read_watchlist_state", @@ -30,7 +30,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "read_alert_rule_matches", description: "Read alert rules and the CVEs that currently match them.", access: "read", - features: ["daily_digest"], + features: ["daily_digest", "alert_investigation"], }, { name: "read_project_records", @@ -48,7 +48,7 @@ const AI_TOOL_REGISTRY: AIToolDefinition[] = [ name: "read_triage_state", description: "Read user-scoped triage notes, ownership, tags, and workflow status.", access: "read", - features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary"], + features: ["cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary", "alert_investigation"], }, { name: "write_triage_state", diff --git a/src/lib/ai.ts b/src/lib/ai.ts index ffb7bf4..22ea2a4 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,12 +1,14 @@ import { AISettings } from "./types"; export { + buildHeuristicAlertInvestigation, buildHeuristicCveInsight, buildHeuristicDigest, buildHeuristicProjectSummary, buildHeuristicRemediationPlan, buildHeuristicTriageSuggestion, buildHeuristicWatchlistReview, + generateAlertInvestigation, generateCveInsight, generateDigest, generateProjectSummary, @@ -21,6 +23,7 @@ export { } from "./ai-service"; export type { + AlertInvestigationInput, CveInsightInput, DigestInput, ProjectSummaryInput, diff --git a/src/lib/types.ts b/src/lib/types.ts index aed333d..54cf257 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -330,6 +330,22 @@ export interface AIProjectSummary { }; } +export interface AIAlertInvestigationMatch { + id: string; + summary: string; + rationale: string; + unread: boolean; +} + +export interface AIAlertInvestigation { + ruleName: string; + summary: string; + whyMatched: string[]; + topMatches: AIAlertInvestigationMatch[]; + recommendedAction: string; + nextSteps: string[]; +} + export type AISearchFilterField = "query" | "vendor" | "product" | "cwe" | "since" | "minSeverity" | "sort"; export interface AISearchAppliedFilter { @@ -389,7 +405,7 @@ export interface AIRunRecord { export type AIProvider = "heuristic" | "openai" | "anthropic"; -export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent" | "watchlist_analyst" | "project_summary"; +export type AIFeature = "search_assistant" | "cve_insight" | "daily_digest" | "triage_agent" | "remediation_agent" | "watchlist_analyst" | "project_summary" | "alert_investigation"; export interface AISettings { provider: AIProvider; diff --git a/src/lib/workspace-store.ts b/src/lib/workspace-store.ts index b27eb4a..8af37ad 100644 --- a/src/lib/workspace-store.ts +++ b/src/lib/workspace-store.ts @@ -121,6 +121,11 @@ export async function listAlertRulesForUser(userId: string): Promise { + const rules = await listAlertRulesForUser(userId); + return rules.find((rule) => rule.id === id) ?? null; +} + export async function createAlertRuleForUser(userId: string, name: string, search: SearchState): Promise { const record: AlertRule = { id: crypto.randomUUID(), diff --git a/tests/ai.test.ts b/tests/ai.test.ts index 27817e1..fb40b74 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildHeuristicAlertInvestigation, buildHeuristicCveInsight, buildHeuristicDigest, buildHeuristicProjectSummary, @@ -289,6 +290,52 @@ test("buildHeuristicProjectSummary returns executive analyst and engineering vie assert.equal(result.engineering.bullets.length >= 1, true); }); +test("buildHeuristicAlertInvestigation explains matches and recommends follow-up", () => { + const result = buildHeuristicAlertInvestigation({ + rule: { + id: "rule-1", + name: "Critical Edge Alerts", + lastCheckedAt: "2026-03-07T10:00:00.000Z", + search: { + query: "edge", + vendor: "", + product: "gateway", + cwe: "", + since: "", + minSeverity: "HIGH", + sort: "risk_desc", + page: 1, + perPage: 20, + }, + }, + matches: [ + { + id: "CVE-2026-8001", + summary: "Critical gateway issue", + severity: "CRITICAL", + kev: true, + published: "2026-03-07T09:00:00.000Z", + modified: "2026-03-07T11:00:00.000Z", + unread: true, + }, + { + id: "CVE-2026-8002", + summary: "High gateway issue", + severity: "HIGH", + kev: false, + published: "2026-03-07T08:00:00.000Z", + modified: "2026-03-07T09:00:00.000Z", + unread: false, + }, + ], + }); + + assert.equal(result.ruleName, "Critical Edge Alerts"); + assert.equal(result.whyMatched.length >= 2, true); + assert.equal(result.topMatches.length, 2); + assert.match(result.recommendedAction, /unread|review/i); +}); + test("preparePromptInputForFeature redacts sensitive notes and project metadata for external providers", () => { const previous = { AI_PROVIDER: process.env.AI_PROVIDER, @@ -356,6 +403,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove AI_WATCHLIST_ANALYST_MODEL: process.env.AI_WATCHLIST_ANALYST_MODEL, AI_PROJECT_SUMMARY_PROVIDER: process.env.AI_PROJECT_SUMMARY_PROVIDER, AI_PROJECT_SUMMARY_MODEL: process.env.AI_PROJECT_SUMMARY_MODEL, + AI_ALERT_INVESTIGATION_PROVIDER: process.env.AI_ALERT_INVESTIGATION_PROVIDER, + AI_ALERT_INVESTIGATION_MODEL: process.env.AI_ALERT_INVESTIGATION_MODEL, AI_ALLOW_SENSITIVE_MODEL_DATA: process.env.AI_ALLOW_SENSITIVE_MODEL_DATA, }; @@ -378,6 +427,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove process.env.AI_WATCHLIST_ANALYST_MODEL = "claude-watchlist"; process.env.AI_PROJECT_SUMMARY_PROVIDER = "openai"; process.env.AI_PROJECT_SUMMARY_MODEL = "gpt-project"; + process.env.AI_ALERT_INVESTIGATION_PROVIDER = "anthropic"; + process.env.AI_ALERT_INVESTIGATION_MODEL = "claude-alerts"; delete process.env.AI_ALLOW_SENSITIVE_MODEL_DATA; try { @@ -389,6 +440,7 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove const remediationAgent = summary.featureConfigurations.find((item) => item.feature === "remediation_agent"); const watchlistAnalyst = summary.featureConfigurations.find((item) => item.feature === "watchlist_analyst"); const projectSummary = summary.featureConfigurations.find((item) => item.feature === "project_summary"); + const alertInvestigation = summary.featureConfigurations.find((item) => item.feature === "alert_investigation"); assert.equal(summary.provider, "openai"); assert.equal(summary.model, "gpt-global"); @@ -409,6 +461,8 @@ test("getServerAIConfigurationSummary applies per-feature provider and model ove assert.equal(watchlistAnalyst?.model, "claude-watchlist"); assert.equal(projectSummary?.provider, "openai"); assert.equal(projectSummary?.model, "gpt-project"); + assert.equal(alertInvestigation?.provider, "anthropic"); + assert.equal(alertInvestigation?.model, "claude-alerts"); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) { From c469e29a9fe67be6ffb0887744724fd98e3e6b9f Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:58:24 +0100 Subject: [PATCH 20/30] feat(ai): add usage and cost visibility --- src/components/AISettingsPageClient.tsx | 26 +++++++++- src/lib/ai-runs-store.ts | 66 +++++++++++++++++++++++++ src/lib/types.ts | 3 ++ tests/ai-runs-store.test.ts | 38 ++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 8abba3d..5791e53 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -14,6 +14,8 @@ import { AIRunRecord } from "@/lib/types"; import WorkspaceDataPanel from "@/components/WorkspaceDataPanel"; export default function AISettingsPageClient({ summary, recentRuns }: { summary: ServerAIConfigurationSummary; recentRuns: AIRunRecord[] }) { + const usageSummary = summarizeAIRunUsage(recentRuns); + return (
@@ -144,9 +146,16 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: Recent AI Runs - Read-only history of recent prompts, outcomes, tool traces, and failures. + Read-only history of prompts, outcomes, latency, estimated tokens, and estimated provider cost. + + + + + + + {recentRuns.length > 0 ? (
{recentRuns.map((run) => ( @@ -157,6 +166,8 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: {run.provider}{run.model ? ` • ${run.model}` : ""} {new Date(run.createdAt).toLocaleString("en-US")} {run.durationMs}ms + ~{((run.promptTokensEstimate ?? 0) + (run.outputTokensEstimate ?? 0)).toLocaleString("en-US")} tokens + ${(run.estimatedCostUsd ?? 0).toFixed(4)}
@@ -212,3 +223,16 @@ function RunBlock({ title, value }: { title: string; value: string }) {
); } + +function summarizeAIRunUsage(runs: AIRunRecord[]) { + const totalDurationMs = runs.reduce((sum, run) => sum + run.durationMs, 0); + const totalTokens = runs.reduce((sum, run) => sum + (run.promptTokensEstimate ?? 0) + (run.outputTokensEstimate ?? 0), 0); + const totalCostUsd = runs.reduce((sum, run) => sum + (run.estimatedCostUsd ?? 0), 0); + + return { + runCount: runs.length, + averageDurationMs: runs.length > 0 ? Math.round(totalDurationMs / runs.length) : 0, + totalTokens, + totalCostUsd, + }; +} diff --git a/src/lib/ai-runs-store.ts b/src/lib/ai-runs-store.ts index cab64de..1c5cdd6 100644 --- a/src/lib/ai-runs-store.ts +++ b/src/lib/ai-runs-store.ts @@ -78,6 +78,7 @@ interface AIRunRow { } function normalizeAIRunRow(row: AIRunRow): AIRunRecord { + const usage = estimateAIRunUsage(row.provider, row.model, row.prompt, row.output); return { id: row.id, feature: isAIFeature(row.feature) ? row.feature : "search_assistant", @@ -90,11 +91,15 @@ function normalizeAIRunRow(row: AIRunRow): AIRunRecord { toolCalls: parseToolCalls(row.toolCallsJson), error: row.error, durationMs: Number.isFinite(row.durationMs) ? row.durationMs : 0, + promptTokensEstimate: usage.promptTokensEstimate, + outputTokensEstimate: usage.outputTokensEstimate, + estimatedCostUsd: usage.estimatedCostUsd, createdAt: row.createdAt, }; } function normalizeAIRun(record: AIRunRecord): AIRunRecord { + const usage = estimateAIRunUsage(record.provider, record.model, record.prompt, record.output); return { id: record.id, feature: record.feature, @@ -111,6 +116,9 @@ function normalizeAIRun(record: AIRunRecord): AIRunRecord { : [], error: record.error, durationMs: Number.isFinite(record.durationMs) ? record.durationMs : 0, + promptTokensEstimate: usage.promptTokensEstimate, + outputTokensEstimate: usage.outputTokensEstimate, + estimatedCostUsd: usage.estimatedCostUsd, createdAt: record.createdAt, }; } @@ -135,6 +143,64 @@ function normalizeLimit(limit: number): number { return Math.min(Math.max(Math.floor(limit), 1), 100); } +function estimateAIRunUsage(provider: string, model: string, prompt: string, output: string): { + promptTokensEstimate: number; + outputTokensEstimate: number; + estimatedCostUsd: number; +} { + const promptTokensEstimate = estimateTokens(prompt); + const outputTokensEstimate = estimateTokens(output); + + if (provider === "heuristic") { + return { + promptTokensEstimate, + outputTokensEstimate, + estimatedCostUsd: 0, + }; + } + + const pricing = getModelPricing(provider, model); + const estimatedCostUsd = + (promptTokensEstimate / 1_000_000) * pricing.inputPerMillionUsd + + (outputTokensEstimate / 1_000_000) * pricing.outputPerMillionUsd; + + return { + promptTokensEstimate, + outputTokensEstimate, + estimatedCostUsd: Number(estimatedCostUsd.toFixed(6)), + }; +} + +function estimateTokens(value: string): number { + if (!value) { + return 0; + } + + return Math.max(1, Math.ceil(value.length / 4)); +} + +function getModelPricing(provider: string, model: string): { inputPerMillionUsd: number; outputPerMillionUsd: number } { + const normalized = model.toLowerCase(); + + if (provider === "openai") { + if (normalized.includes("gpt-4.1-mini") || normalized.includes("mini")) { + return { inputPerMillionUsd: 0.4, outputPerMillionUsd: 1.6 }; + } + + return { inputPerMillionUsd: 2, outputPerMillionUsd: 8 }; + } + + if (provider === "anthropic") { + if (normalized.includes("haiku")) { + return { inputPerMillionUsd: 0.8, outputPerMillionUsd: 4 }; + } + + return { inputPerMillionUsd: 3, outputPerMillionUsd: 15 }; + } + + return { inputPerMillionUsd: 0, outputPerMillionUsd: 0 }; +} + function isAIFeature(value: string): value is AIFeature { return ["search_assistant", "cve_insight", "daily_digest", "triage_agent", "remediation_agent", "watchlist_analyst", "project_summary", "alert_investigation"].includes(value); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 54cf257..c9f296f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -400,6 +400,9 @@ export interface AIRunRecord { toolCalls: AISearchToolTrace[]; error: string; durationMs: number; + promptTokensEstimate?: number; + outputTokensEstimate?: number; + estimatedCostUsd?: number; createdAt: string; } diff --git a/tests/ai-runs-store.test.ts b/tests/ai-runs-store.test.ts index d801f31..5746eb0 100644 --- a/tests/ai-runs-store.test.ts +++ b/tests/ai-runs-store.test.ts @@ -45,6 +45,8 @@ test("appendAIRun stores newest runs first and listRecentAIRuns enforces the lim assert.equal(allRuns[0].id, "run-2"); assert.equal(allRuns[1].id, "run-1"); assert.equal(allRuns[0].toolCalls[0]?.tool, "inspect_available_filters"); + assert.equal((allRuns[0].promptTokensEstimate ?? 0) > 0, true); + assert.equal(allRuns[0].estimatedCostUsd, 0); assert.equal(limitedRuns.length, 1); assert.equal(limitedRuns[0].id, "run-2"); } finally { @@ -56,3 +58,39 @@ test("appendAIRun stores newest runs first and listRecentAIRuns enforces the lim await fs.rm(tempDir, { recursive: true, force: true }); } }); + +test("listRecentAIRuns estimates tokens and provider cost for configured runs", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cvesearch-ai-runs-cost-")); + const previous = process.env.AI_RUNS_FILE; + process.env.AI_RUNS_FILE = path.join(tempDir, "ai-runs.json"); + + const run: AIRunRecord = { + id: "run-openai", + feature: "cve_insight", + provider: "openai", + model: "gpt-4.1-mini", + mode: "configured", + status: "success", + prompt: "a".repeat(400), + output: "b".repeat(200), + toolCalls: [], + error: "", + durationMs: 42, + createdAt: "2026-03-06T11:02:00.000Z", + }; + + try { + await appendAIRun(run); + const [stored] = await listRecentAIRuns(1); + assert.equal(stored.promptTokensEstimate, 100); + assert.equal(stored.outputTokensEstimate, 50); + assert.equal((stored.estimatedCostUsd ?? 0) > 0, true); + } finally { + if (previous === undefined) { + delete process.env.AI_RUNS_FILE; + } else { + process.env.AI_RUNS_FILE = previous; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); From 7b5cb1a6220e9b157f1d40e7cbbc47e4bcacb256 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 17:58:49 +0100 Subject: [PATCH 21/30] docs(todo): update AI workflow progress --- docs/todo.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 255273a..fbbc7b8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -27,7 +27,7 @@ - [x] add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses - [x] upgrade the AI search assistant from single-shot prompt interpretation to an agent that can clarify intent, inspect available filters, and build multi-step searches - [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions -- add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action +- [x] add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action - [x] add search explanation output that shows exactly which fields, filters, and assumptions the AI applied - improve data normalization for aliases, linked vulnerabilities, affected products, and reference metadata - [x] add stronger schema validation around upstream CIRCL payloads and AI-generated JSON @@ -43,10 +43,10 @@ - [x] add bulk actions for watchlist, triage, and project assignment - [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - [x] add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review -- add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats +- [x] add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view -- add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications -- add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default +- [x] add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications +- [x] add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default ### Build Later @@ -58,7 +58,7 @@ - add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions - add asset or product inventory mapping so CVEs can be linked to affected internal systems - add team-facing notifications and scheduled digest delivery -- add usage tracking, latency metrics, and cost visibility for each AI feature +- [x] add usage tracking, latency metrics, and cost visibility for each AI feature - expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent - add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" - add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact @@ -119,20 +119,20 @@ - [x] add an AI triage agent that uses CVE detail, severity, references, KEV and EPSS signals, and project context to recommend priority, ownership, and next actions - [x] add an AI remediation agent that drafts remediation plans, compensating controls, validation steps, and rollout notes per vulnerability - [x] add an AI watchlist analyst agent that reviews new matches, clusters related issues, and highlights what changed since the last review -- add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats -- add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action +- [x] add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats +- [x] add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action - [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view - add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact - add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches -- add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications +- [x] add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications ## AI Safety and Operations - [x] stop storing provider API keys in browser local storage; move to server-side secrets or secure per-user encrypted storage -- add usage tracking, latency metrics, and cost visibility for each AI feature +- [x] add usage tracking, latency metrics, and cost visibility for each AI feature - [x] add prompt and version management so changes to agent behavior are explicit and reversible - [x] add fallback behavior for tool failures, upstream CIRCL outages, and malformed model responses -- add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default +- [x] add redaction rules so sensitive notes or project metadata are not sent to third-party model providers by default ## Search and Data Quality From 050061e3cad42ffb76681cb54e7815a29fffd014 Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 7 Mar 2026 18:15:28 +0100 Subject: [PATCH 22/30] feat(ai): add inventory-based exposure analysis --- docs/todo.md | 8 +- src/app/api/ai/exposure/[id]/route.ts | 56 +++++++ src/app/api/inventory/[id]/route.ts | 50 ++++++ src/app/api/inventory/route.ts | 39 +++++ src/app/api/workspace/export/route.ts | 5 +- src/app/api/workspace/import/route.ts | 25 ++- src/app/cve/[id]/page.tsx | 3 + src/app/settings/page.tsx | 14 +- src/components/AIExposurePanel.tsx | 119 ++++++++++++++ src/components/AISettingsPageClient.tsx | 16 +- src/components/InventoryAssetsPanel.tsx | 175 +++++++++++++++++++++ src/components/WorkspaceDataPanel.tsx | 11 +- src/lib/ai-prompts.ts | 16 ++ src/lib/ai-runs-store.ts | 2 +- src/lib/ai-service.ts | 197 +++++++++++++++++++++++- src/lib/ai-tool-registry.ts | 12 +- src/lib/ai.ts | 3 + src/lib/db.ts | 17 ++ src/lib/inventory.ts | 98 ++++++++++++ src/lib/types.ts | 18 ++- src/lib/workspace-store.ts | 176 ++++++++++++++++++++- src/lib/workspace-types.ts | 14 ++ tests/ai.test.ts | 47 ++++++ tests/workspace-store.test.ts | 26 ++++ 24 files changed, 1125 insertions(+), 22 deletions(-) create mode 100644 src/app/api/ai/exposure/[id]/route.ts create mode 100644 src/app/api/inventory/[id]/route.ts create mode 100644 src/app/api/inventory/route.ts create mode 100644 src/components/AIExposurePanel.tsx create mode 100644 src/components/InventoryAssetsPanel.tsx create mode 100644 src/lib/inventory.ts diff --git a/docs/todo.md b/docs/todo.md index fbbc7b8..cd64dd7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -56,12 +56,12 @@ - [x] add import/export for projects, triage state, saved views, and watchlists - expand project management with owners, due dates, labels, status, and timeline views - add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions -- add asset or product inventory mapping so CVEs can be linked to affected internal systems +- [x] add asset or product inventory mapping so CVEs can be linked to affected internal systems - add team-facing notifications and scheduled digest delivery - [x] add usage tracking, latency metrics, and cost visibility for each AI feature - expand natural-language search to understand CWE families, date ranges, product aliases, exploitability, and remediation intent - add saved prompt templates for common analyst tasks such as "show newly published critical CVEs affecting OpenSSL this week" -- add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact +- [x] add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact - add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches ### Suggested First Slice @@ -94,7 +94,7 @@ - expand project management with owners, due dates, labels, status, and timeline views - add a real vulnerability management workflow with assignment, SLA tracking, remediation state, and exceptions -- add asset or product inventory mapping so CVEs can be linked to affected internal systems +- [x] add asset or product inventory mapping so CVEs can be linked to affected internal systems - [x] enrich prioritization with CISA KEV, EPSS-first sorting, and exploit/reference signals - add team-facing notifications and scheduled digest delivery @@ -122,7 +122,7 @@ - [x] add an AI project summary agent that turns project state into executive, analyst, and engineering summaries with different output formats - [x] add an AI alert investigation agent that explains why a rule matched and proposes the next best analyst action - [x] add an AI duplicate and cluster agent that groups aliases, related advisories, and linked vulnerabilities into a shared incident view -- add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact +- [x] add an AI exposure agent that maps vulnerabilities against tracked vendors, products, and assets to estimate likely internal impact - add a conversational workspace where an agent can answer questions over the user’s watchlist, alerts, projects, and saved searches - [x] add human approval checkpoints before any agent writes triage state, modifies projects, or sends notifications diff --git a/src/app/api/ai/exposure/[id]/route.ts b/src/app/api/ai/exposure/[id]/route.ts new file mode 100644 index 0000000..2429b7f --- /dev/null +++ b/src/app/api/ai/exposure/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { generateExposureAssessment } from "@/lib/ai-service"; +import { listProjects } from "@/lib/projects-store"; +import { getCVEByIdServer } from "@/lib/server-api"; +import { CVEDetail } from "@/lib/types"; +import { listInventoryAssetsForUser, readTriageRecordForUser } from "@/lib/workspace-store"; + +export const POST = withRouteProtection(async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const requestDetail = isCVEDetail(body?.detail) ? body.detail : null; + const detail = await getCVEByIdServer(decodeURIComponent(id)).catch(() => requestDetail); + + if (!detail) { + return applyWorkspaceSession(NextResponse.json({ error: "Failed to load CVE detail for AI exposure" }, { status: 502 }), session); + } + + const [triage, inventoryAssets, projects] = await Promise.all([ + readTriageRecordForUser(session.userId, detail.id), + listInventoryAssetsForUser(session.userId), + listProjects().catch(() => []), + ]); + const relatedProjects = projects.filter((project) => project.items.some((item) => item.cveId === detail.id)); + const assessment = await generateExposureAssessment({ + detail, + triage, + relatedProjects: relatedProjects.map((project) => ({ + name: project.name, + items: project.items, + updatedAt: project.updatedAt, + })), + inventoryAssets: inventoryAssets.map((asset) => ({ + id: asset.id, + name: asset.name, + vendor: asset.vendor, + product: asset.product, + version: asset.version, + environment: asset.environment, + criticality: asset.criticality, + notes: asset.notes, + })), + }); + + return applyWorkspaceSession(NextResponse.json(assessment), session); +}, { + route: "/api/ai/exposure/[id]", + errorMessage: "Failed to generate AI exposure assessment", + rateLimit: API_RATE_LIMITS.aiWrite, +}); + +function isCVEDetail(value: unknown): value is CVEDetail { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) && typeof (value as Record).id === "string"; +} diff --git a/src/app/api/inventory/[id]/route.ts b/src/app/api/inventory/[id]/route.ts new file mode 100644 index 0000000..d5948f4 --- /dev/null +++ b/src/app/api/inventory/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { deleteInventoryAssetForUser, updateInventoryAssetForUser } from "@/lib/workspace-store"; +import { InventoryAssetRecord } from "@/lib/workspace-types"; + +export const PATCH = withRouteProtection(async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const body = await request.json().catch(() => null); + const asset = await updateInventoryAssetForUser(session.userId, id, normalizeInventoryAssetPatch(body)); + + if (!asset) { + return applyWorkspaceSession(NextResponse.json({ error: "Inventory asset not found" }, { status: 404 }), session); + } + + return applyWorkspaceSession(NextResponse.json(asset), session); +}, { + route: "/api/inventory/[id]", + errorMessage: "Failed to update inventory asset", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +export const DELETE = withRouteProtection(async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const session = getOrCreateWorkspaceSession(request); + const { id } = await context.params; + const deleted = await deleteInventoryAssetForUser(session.userId, id); + if (!deleted) { + return applyWorkspaceSession(NextResponse.json({ error: "Inventory asset not found" }, { status: 404 }), session); + } + + return applyWorkspaceSession(NextResponse.json({ success: true }), session); +}, { + route: "/api/inventory/[id]", + errorMessage: "Failed to delete inventory asset", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +function normalizeInventoryAssetPatch(value: unknown): Partial> { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return { + ...(typeof record.name === "string" ? { name: record.name } : {}), + ...(typeof record.vendor === "string" ? { vendor: record.vendor } : {}), + ...(typeof record.product === "string" ? { product: record.product } : {}), + ...(typeof record.version === "string" ? { version: record.version } : {}), + ...(typeof record.environment === "string" ? { environment: record.environment } : {}), + ...(record.criticality === "critical" || record.criticality === "high" || record.criticality === "medium" || record.criticality === "low" ? { criticality: record.criticality } : {}), + ...(typeof record.notes === "string" ? { notes: record.notes } : {}), + }; +} diff --git a/src/app/api/inventory/route.ts b/src/app/api/inventory/route.ts new file mode 100644 index 0000000..9171c35 --- /dev/null +++ b/src/app/api/inventory/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_RATE_LIMITS, withRouteProtection } from "@/lib/api-route-guard"; +import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-session"; +import { createInventoryAssetForUser, listInventoryAssetsForUser } from "@/lib/workspace-store"; +import { InventoryAssetRecord } from "@/lib/workspace-types"; + +export const GET = withRouteProtection(async function GET(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const response = NextResponse.json(await listInventoryAssetsForUser(session.userId)); + return applyWorkspaceSession(response, session); +}, { + route: "/api/inventory", + errorMessage: "Failed to load inventory assets", + rateLimit: API_RATE_LIMITS.workspaceReads, +}); + +export const POST = withRouteProtection(async function POST(request: NextRequest) { + const session = getOrCreateWorkspaceSession(request); + const body = await request.json().catch(() => null); + const asset = await createInventoryAssetForUser(session.userId, normalizeInventoryAssetInput(body)); + return applyWorkspaceSession(NextResponse.json(asset, { status: 201 }), session); +}, { + route: "/api/inventory", + errorMessage: "Failed to create inventory asset", + rateLimit: API_RATE_LIMITS.workspaceMutations, +}); + +function normalizeInventoryAssetInput(value: unknown): Omit { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return { + name: typeof record.name === "string" ? record.name : "", + vendor: typeof record.vendor === "string" ? record.vendor : "", + product: typeof record.product === "string" ? record.product : "", + version: typeof record.version === "string" ? record.version : "", + environment: typeof record.environment === "string" ? record.environment : "", + criticality: record.criticality === "critical" || record.criticality === "high" || record.criticality === "medium" || record.criticality === "low" ? record.criticality : "medium", + notes: typeof record.notes === "string" ? record.notes : "", + }; +} diff --git a/src/app/api/workspace/export/route.ts b/src/app/api/workspace/export/route.ts index bfbf277..0ff55fa 100644 --- a/src/app/api/workspace/export/route.ts +++ b/src/app/api/workspace/export/route.ts @@ -4,6 +4,7 @@ import { applyWorkspaceSession, getOrCreateWorkspaceSession } from "@/lib/auth-s import { listProjects } from "@/lib/projects-store"; import { listAlertRulesForUser, + listInventoryAssetsForUser, listSavedViewsForUser, listWatchlist, readTriageMapForUser, @@ -12,10 +13,11 @@ import { WorkspaceExportSnapshot } from "@/lib/workspace-types"; export const GET = withRouteProtection(async function GET(request: NextRequest) { const session = getOrCreateWorkspaceSession(request); - const [watchlist, savedViews, alertRules, triageMap, projects] = await Promise.all([ + const [watchlist, savedViews, alertRules, inventoryAssets, triageMap, projects] = await Promise.all([ listWatchlist(session.userId), listSavedViewsForUser(session.userId), listAlertRulesForUser(session.userId), + listInventoryAssetsForUser(session.userId), readTriageMapForUser(session.userId), listProjects(), ]); @@ -26,6 +28,7 @@ export const GET = withRouteProtection(async function GET(request: NextRequest) watchlist, savedViews, alertRules, + inventoryAssets, triageRecords: Object.values(triageMap), projects, }; diff --git a/src/app/api/workspace/import/route.ts b/src/app/api/workspace/import/route.ts index a838b6b..e8d88b2 100644 --- a/src/app/api/workspace/import/route.ts +++ b/src/app/api/workspace/import/route.ts @@ -5,7 +5,7 @@ import { importProjects } from "@/lib/projects-store"; import { createDefaultTriageRecord, normalizeTriageRecord, TriageRecord } from "@/lib/triage-shared"; import { ProjectRecord } from "@/lib/types"; import { importWorkspaceStateForUser } from "@/lib/workspace-store"; -import { AlertRule, SavedView, WorkspaceImportMode } from "@/lib/workspace-types"; +import { AlertRule, InventoryAssetRecord, SavedView, WorkspaceImportMode } from "@/lib/workspace-types"; export const POST = withRouteProtection(async function POST(request: NextRequest) { const session = getOrCreateWorkspaceSession(request); @@ -26,6 +26,9 @@ export const POST = withRouteProtection(async function POST(request: NextRequest const alertRules = Array.isArray(snapshot.alertRules) ? snapshot.alertRules.flatMap((value: unknown) => (isAlertRule(value) ? [value] : [])) : []; + const inventoryAssets = Array.isArray(snapshot.inventoryAssets) + ? snapshot.inventoryAssets.flatMap((value: unknown) => (isInventoryAsset(value) ? [value] : [])) + : []; const triageRecords = Array.isArray(snapshot.triageRecords) ? snapshot.triageRecords.flatMap((value: unknown) => (isTriageRecord(value) ? [normalizeTriageRecord(value)] : [])) : []; @@ -39,6 +42,7 @@ export const POST = withRouteProtection(async function POST(request: NextRequest watchlist, savedViews, alertRules, + inventoryAssets, triageRecords, }, mode @@ -52,6 +56,7 @@ export const POST = withRouteProtection(async function POST(request: NextRequest watchlist: watchlist.length, savedViews: savedViews.length, alertRules: alertRules.length, + inventoryAssets: inventoryAssets.length, triageRecords: triageRecords.length, projects: projects.length, }, @@ -78,6 +83,24 @@ function isAlertRule(value: unknown): value is AlertRule { && typeof (value as Record).search === "object"; } +function isInventoryAsset(value: unknown): value is InventoryAssetRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return typeof record.id === "string" + && typeof record.name === "string" + && typeof record.vendor === "string" + && typeof record.product === "string" + && typeof record.version === "string" + && typeof record.environment === "string" + && typeof record.criticality === "string" + && typeof record.notes === "string" + && typeof record.createdAt === "string" + && typeof record.updatedAt === "string"; +} + function isTriageRecord(value: unknown): value is TriageRecord { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; diff --git a/src/app/cve/[id]/page.tsx b/src/app/cve/[id]/page.tsx index aa20396..0c4c852 100644 --- a/src/app/cve/[id]/page.tsx +++ b/src/app/cve/[id]/page.tsx @@ -17,6 +17,7 @@ import CopyLinkButton from "@/components/CopyLinkButton"; import TriagePanel from "@/components/TriagePanel"; import ProjectPickerButton from "@/components/ProjectPickerButton"; import AICveInsightPanel from "@/components/AICveInsightPanel"; +import AIExposurePanel from "@/components/AIExposurePanel"; import AIRemediationPlanPanel from "@/components/AIRemediationPlanPanel"; export default function CVEDetailPage({ params }: { params: Promise<{ id: string }> }) { @@ -174,6 +175,8 @@ export default function CVEDetailPage({ params }: { params: Promise<{ id: string + + {aliases.length > 0 && (
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index c1fd854..202a7c1 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,9 +1,19 @@ +import { cookies } from "next/headers"; import AISettingsPageClient from "@/components/AISettingsPageClient"; import { getRecentAIRuns, getServerAIConfigurationSummary } from "@/lib/ai-service"; +import { listInventoryAssetsForUser } from "@/lib/workspace-store"; +import { getOrCreateWorkspaceSession } from "@/lib/auth-session"; export default async function SettingsPage() { + const cookieStore = await cookies(); + const session = getOrCreateWorkspaceSession(new Request("https://example.test/settings", { + headers: { cookie: cookieStore.toString() }, + })); const summary = getServerAIConfigurationSummary(); - const recentRuns = await getRecentAIRuns(12); + const [recentRuns, inventoryAssets] = await Promise.all([ + getRecentAIRuns(12), + listInventoryAssetsForUser(session.userId), + ]); - return ; + return ; } diff --git a/src/components/AIExposurePanel.tsx b/src/components/AIExposurePanel.tsx new file mode 100644 index 0000000..752fc95 --- /dev/null +++ b/src/components/AIExposurePanel.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AIExposureAssessment, CVEDetail } from "@/lib/types"; +import { INVENTORY_UPDATED_EVENT } from "@/lib/inventory"; +import { TRIAGE_UPDATED_EVENT } from "@/lib/triage"; + +export default function AIExposurePanel({ cveId, detail }: { cveId: string; detail?: CVEDetail | null }) { + const [assessment, setAssessment] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/ai/exposure/${encodeURIComponent(cveId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ detail }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || "Failed to load AI exposure assessment"); + } + + if (!cancelled) { + setAssessment(data); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load AI exposure assessment"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + window.addEventListener(TRIAGE_UPDATED_EVENT, load); + window.addEventListener(INVENTORY_UPDATED_EVENT, load); + return () => { + cancelled = true; + window.removeEventListener(TRIAGE_UPDATED_EVENT, load); + window.removeEventListener(INVENTORY_UPDATED_EVENT, load); + }; + }, [cveId, detail]); + + return ( +
+
+
+

AI Exposure Agent

+

Maps this vulnerability against tracked inventory assets, product mappings, and project context to estimate likely internal impact.

+
+ {assessment ? : null} +
+ + {loading ?

Estimating internal exposure...

: null} + {error ?

{error}

: null} + + {assessment && !loading ? ( +
+

{assessment.summary}

+ +
+ +
+

Matched Assets

+ {assessment.matchedAssets.length > 0 ? ( +
+ {assessment.matchedAssets.map((asset) => ( +
+
+ {asset.assetName} + {asset.confidence} +
+

{asset.rationale}

+
+ {asset.matchingSignals.map((signal) => ( + {signal} + ))} +
+
+ ))} +
+ ) : ( +

No tracked assets matched this CVE yet. Add inventory mappings in settings to improve exposure accuracy.

+ )} +
+ +
+
+ ) : null} +
+ ); +} + +function Section({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); +} + +function Badge({ label }: { label: string }) { + return {label}; +} diff --git a/src/components/AISettingsPageClient.tsx b/src/components/AISettingsPageClient.tsx index 5791e53..9c75ac3 100644 --- a/src/components/AISettingsPageClient.tsx +++ b/src/components/AISettingsPageClient.tsx @@ -11,9 +11,19 @@ import { } from "@radix-ui/themes"; import { ServerAIConfigurationSummary } from "@/lib/ai-service"; import { AIRunRecord } from "@/lib/types"; +import { InventoryAssetRecord } from "@/lib/workspace-types"; +import InventoryAssetsPanel from "@/components/InventoryAssetsPanel"; import WorkspaceDataPanel from "@/components/WorkspaceDataPanel"; -export default function AISettingsPageClient({ summary, recentRuns }: { summary: ServerAIConfigurationSummary; recentRuns: AIRunRecord[] }) { +export default function AISettingsPageClient({ + summary, + recentRuns, + inventoryAssets, +}: { + summary: ServerAIConfigurationSummary; + recentRuns: AIRunRecord[]; + inventoryAssets: InventoryAssetRecord[]; +}) { const usageSummary = summarizeAIRunUsage(recentRuns); return ( @@ -39,7 +49,7 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: - Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_PROJECT_SUMMARY_PROVIDER`, `AI_PROJECT_SUMMARY_MODEL`, `AI_ALERT_INVESTIGATION_PROVIDER`, `AI_ALERT_INVESTIGATION_MODEL`, `AI_DAILY_DIGEST_PROVIDER`, and `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. + Configure AI providers with environment variables such as `AI_PROVIDER`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_MODEL`. You can override individual flows with `AI_SEARCH_ASSISTANT_PROVIDER`, `AI_SEARCH_ASSISTANT_MODEL`, `AI_CVE_INSIGHT_PROVIDER`, `AI_CVE_INSIGHT_MODEL`, `AI_TRIAGE_AGENT_PROVIDER`, `AI_TRIAGE_AGENT_MODEL`, `AI_REMEDIATION_AGENT_PROVIDER`, `AI_REMEDIATION_AGENT_MODEL`, `AI_WATCHLIST_ANALYST_PROVIDER`, `AI_WATCHLIST_ANALYST_MODEL`, `AI_PROJECT_SUMMARY_PROVIDER`, `AI_PROJECT_SUMMARY_MODEL`, `AI_ALERT_INVESTIGATION_PROVIDER`, `AI_ALERT_INVESTIGATION_MODEL`, `AI_EXPOSURE_AGENT_PROVIDER`, `AI_EXPOSURE_AGENT_MODEL`, and `AI_DAILY_DIGEST_PROVIDER`, `AI_DAILY_DIGEST_MODEL`. No provider API key is persisted in browser storage. @@ -141,6 +151,8 @@ export default function AISettingsPageClient({ summary, recentRuns }: { summary: + + diff --git a/src/components/InventoryAssetsPanel.tsx b/src/components/InventoryAssetsPanel.tsx new file mode 100644 index 0000000..966e25f --- /dev/null +++ b/src/components/InventoryAssetsPanel.tsx @@ -0,0 +1,175 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { Badge, Button, Card, Flex, Grid, Heading, Text, TextArea, TextField } from "@radix-ui/themes"; +import { createInventoryAsset, deleteInventoryAsset, INVENTORY_UPDATED_EVENT, loadInventoryAssets } from "@/lib/inventory"; +import { InventoryAssetRecord } from "@/lib/workspace-types"; + +const DEFAULT_FORM = { + name: "", + vendor: "", + product: "", + version: "", + environment: "production", + criticality: "medium" as InventoryAssetRecord["criticality"], + notes: "", +}; + +export default function InventoryAssetsPanel({ initialAssets }: { initialAssets: InventoryAssetRecord[] }) { + const [assets, setAssets] = useState(initialAssets); + const [form, setForm] = useState(DEFAULT_FORM); + const [busy, setBusy] = useState(null); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + useEffect(() => { + const sync = async () => setAssets(await loadInventoryAssets()); + void sync(); + window.addEventListener(INVENTORY_UPDATED_EVENT, sync); + return () => window.removeEventListener(INVENTORY_UPDATED_EVENT, sync); + }, []); + + const criticalCount = useMemo(() => assets.filter((asset) => asset.criticality === "critical").length, [assets]); + + async function handleCreate() { + if (!form.name.trim() || (!form.vendor.trim() && !form.product.trim())) { + setMessage({ type: "error", text: "Provide an asset name plus at least a vendor or product." }); + return; + } + + setBusy("create"); + setMessage(null); + try { + const created = await createInventoryAsset(form); + setAssets((current) => [created, ...current.filter((asset) => asset.id !== created.id)]); + setForm(DEFAULT_FORM); + setMessage({ type: "success", text: `Added ${created.name} to the inventory.` }); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Failed to create inventory asset" }); + } finally { + setBusy(null); + } + } + + async function handleDelete(id: string) { + setBusy(`delete:${id}`); + setMessage(null); + try { + await deleteInventoryAsset(id); + setAssets((current) => current.filter((asset) => asset.id !== id)); + setMessage({ type: "success", text: "Removed inventory asset." }); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Failed to delete inventory asset" }); + } finally { + setBusy(null); + } + } + + return ( + + +
+ Inventory Mapping + + Track internal systems, vendors, and products so exposure agents can estimate likely impact against your environment. + +
+ + {assets.length} assets + 0 ? "red" : "gray"} variant="soft">{criticalCount} critical + +
+ + + + Add Inventory Asset +
+ + setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Public API Gateway" /> + + + setForm((current) => ({ ...current, environment: event.target.value }))} placeholder="production" /> + + + setForm((current) => ({ ...current, vendor: event.target.value }))} placeholder="Acme" /> + + + setForm((current) => ({ ...current, product: event.target.value }))} placeholder="gateway" /> + + + setForm((current) => ({ ...current, version: event.target.value }))} placeholder="1.2.x" /> + + + + +
+ +