diff --git a/.changeset/dark-webs-shave.md b/.changeset/dark-webs-shave.md new file mode 100644 index 0000000000..a552c56446 --- /dev/null +++ b/.changeset/dark-webs-shave.md @@ -0,0 +1,5 @@ +--- +"dashboard": minor +--- + +Add a beta Risk Events log under Logs for reviewing and managing policy-flagged or blocked findings. diff --git a/client/dashboard/src/components/log-workbench.tsx b/client/dashboard/src/components/log-workbench.tsx new file mode 100644 index 0000000000..c336290156 --- /dev/null +++ b/client/dashboard/src/components/log-workbench.tsx @@ -0,0 +1,81 @@ +import { cn } from "@/lib/utils"; +import type React from "react"; + +export interface LogWorkbenchProps { + title: React.ReactNode; + description?: React.ReactNode; + actions?: React.ReactNode; + filters?: React.ReactNode; + status?: React.ReactNode; + header?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; + detail?: React.ReactNode; + onScroll?: React.UIEventHandler; + scrollRef?: React.Ref; + surfaceClassName?: string; + contentClassName?: string; + className?: string; +} + +export function LogWorkbench({ + title, + description, + actions, + filters, + status, + header, + children, + footer, + detail, + onScroll, + scrollRef, + surfaceClassName, + contentClassName, + className, +}: LogWorkbenchProps) { + return ( + <> +
+
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ? ( +
{actions}
+ ) : null} +
+ {filters ? ( +
{filters}
+ ) : null} +
+ +
+
+ {status} + {header} +
+ {children} +
+ {footer} +
+
+
+ + {detail} + + ); +} diff --git a/client/dashboard/src/components/observe/ObserveTabNav.tsx b/client/dashboard/src/components/observe/ObserveTabNav.tsx index bdbeb56f47..6c4472e6eb 100644 --- a/client/dashboard/src/components/observe/ObserveTabNav.tsx +++ b/client/dashboard/src/components/observe/ObserveTabNav.tsx @@ -1,12 +1,19 @@ import { cn } from "@/lib/utils"; import { Link, useLocation } from "react-router"; import { useSlugs } from "@/contexts/Sdk"; +import { RequireScope } from "@/components/require-scope"; +import type { Scope } from "@/hooks/useRBAC"; import { ReleaseStage, ReleaseStageBadge, } from "@/components/release-stage-badge"; -type Tab = { label: string; href: string; stage?: ReleaseStage }; +type Tab = { + label: string; + href: string; + stage?: ReleaseStage; + scope?: Scope | Scope[]; +}; export function ObserveTabNav({ base }: { base: "insights" | "logs" }) { const { orgSlug, projectSlug } = useSlugs(); @@ -17,7 +24,15 @@ export function ObserveTabNav({ base }: { base: "insights" | "logs" }) { { label: "Tools", href: `${baseSlug}/tools` }, { label: "MCP Servers", href: `${baseSlug}/mcp` }, ...(base === "logs" - ? [{ label: "Agents", href: `${baseSlug}/agents` }] + ? ([ + { + label: "Risk Events", + href: `${baseSlug}/risk-events`, + stage: "beta", + scope: "org:admin", + }, + { label: "Agents", href: `${baseSlug}/agents` }, + ] satisfies Tab[]) : []), ...(base === "insights" ? ([ @@ -37,7 +52,7 @@ export function ObserveTabNav({ base }: { base: "insights" | "logs" }) { const isActive = location.pathname === tab.href || location.pathname.startsWith(tab.href + "/"); - return ( + const link = ( ); + + if (tab.scope) { + return ( + + {link} + + ); + } + + return link; })} ); diff --git a/client/dashboard/src/components/page-header.tsx b/client/dashboard/src/components/page-header.tsx index d2101e0a26..b60e3540b7 100644 --- a/client/dashboard/src/components/page-header.tsx +++ b/client/dashboard/src/components/page-header.tsx @@ -71,6 +71,7 @@ const breadcrumbSubstitutions = { "audit-logs": "Audit Logs", "admin-settings": "Admin Settings", "risk-overview": "Risk Overview", + "risk-events": "Risk Events", "risk-policies": "Risk Policies", // The URL segments `slack` and `clis` are preserved for backwards // compatibility, but the sidebar/route titles were rebranded — map them diff --git a/client/dashboard/src/pages/logs/Logs.tsx b/client/dashboard/src/pages/logs/Logs.tsx index 12be8cc4d5..a6cc181d67 100644 --- a/client/dashboard/src/pages/logs/Logs.tsx +++ b/client/dashboard/src/pages/logs/Logs.tsx @@ -4,6 +4,7 @@ import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; import { LogsTools } from "@/components/observe/LogsTools"; import { ObserveTabNav } from "@/components/observe/ObserveTabNav"; +import RiskEvents from "@/pages/security/RiskEvents"; export function LogsRoot() { return ( @@ -37,3 +38,11 @@ export function LogsMCPPage() { ); } + +export function LogsRiskEventsPage() { + return ( + + + + ); +} diff --git a/client/dashboard/src/pages/security/RiskEvents.tsx b/client/dashboard/src/pages/security/RiskEvents.tsx new file mode 100644 index 0000000000..698b7f3c8a --- /dev/null +++ b/client/dashboard/src/pages/security/RiskEvents.tsx @@ -0,0 +1,497 @@ +import { LogWorkbench } from "@/components/log-workbench"; +import { Drawer, DrawerContent } from "@/components/ui/drawer"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useSdkClient } from "@/contexts/Sdk"; +import { cn } from "@/lib/utils"; +import { ChatDetailPanel } from "@/pages/chatLogs/ChatDetailPanel"; +import type { RiskResult } from "@gram/client/models/components"; +import { + invalidateAllRiskListResults, + invalidateAllRiskListShadowMCPApprovals, + useRiskApproveShadowMCPMutation, + useRiskListPolicies, +} from "@gram/client/react-query/index.js"; +import { Button, Icon } from "@speakeasy-api/moonshine"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { RefreshCw, ShieldOff } from "lucide-react"; +import { useCallback, useMemo, useRef, type RefObject } from "react"; +import { useSearchParams } from "react-router"; +import { toast } from "sonner"; +import { CategoryLabel, MaskedMatch, RuleLabel } from "./risk-ui"; + +const RISK_EVENTS_GRID = + "grid grid-cols-[172px_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1.15fr)_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1.1fr)_110px] gap-3"; + +export default function RiskEvents() { + const client = useSdkClient(); + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedChatId = searchParams.get("chat_id"); + const policyFilter = searchParams.get("policy_id") ?? ""; + const containerRef = useRef(null); + + const setSelectedChatId = useCallback( + (chatId: string | null) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (chatId) { + next.set("chat_id", chatId); + } else { + next.delete("chat_id"); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setPolicyFilter = useCallback( + (policyId: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (policyId) { + next.set("policy_id", policyId); + } else { + next.delete("policy_id"); + } + return next; + }, + { replace: true }, + ); + containerRef.current?.scrollTo({ top: 0 }); + }, + [setSearchParams], + ); + + const { data: policiesData, isLoading: policiesLoading } = + useRiskListPolicies(); + const policies = useMemo( + () => policiesData?.policies ?? [], + [policiesData?.policies], + ); + const policyMessageById = useMemo(() => { + const m = new Map(); + for (const policy of policies) { + if (policy.userMessage && policy.userMessage.trim() !== "") { + m.set(policy.id, policy.userMessage); + } + } + return m; + }, [policies]); + + const resultsQuery = useInfiniteQuery({ + queryKey: ["risk", "results", "list", policyFilter], + queryFn: async ({ pageParam }) => { + return client.risk.results.list({ + cursor: pageParam, + limit: 50, + policyId: policyFilter || undefined, + }); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); + + const results = useMemo( + () => resultsQuery.data?.pages.flatMap((p) => p.results) ?? [], + [resultsQuery.data], + ); + const totalCount = resultsQuery.data?.pages[0]?.totalCount ?? results.length; + const isInitialLoading = policiesLoading || resultsQuery.isLoading; + + const approveMutation = useRiskApproveShadowMCPMutation(); + const handleExclude = useCallback( + (policyId: string, match: string, serverName?: string) => { + approveMutation.mutate( + { + request: { + approveShadowMCPRequestBody: { + policyId, + match, + serverName, + }, + }, + }, + { + onSuccess: () => { + toast.success("Excluded from policy"); + queryClient.invalidateQueries({ + queryKey: ["risk", "results", "list"], + }); + invalidateAllRiskListResults(queryClient); + invalidateAllRiskListShadowMCPApprovals(queryClient); + }, + onError: (err) => { + toast.error(`Failed to exclude: ${err.message ?? "unknown error"}`); + }, + }, + ); + }, + [approveMutation, queryClient], + ); + + const handleScroll = useCallback( + (e: React.UIEvent) => { + const container = e.currentTarget; + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + + if (resultsQuery.isFetchingNextPage || resultsQuery.isFetching) return; + if (!resultsQuery.hasNextPage) return; + + if (distanceFromBottom < 200) { + resultsQuery.fetchNextPage(); + } + }, + [resultsQuery], + ); + + return ( + resultsQuery.refetch()} + disabled={resultsQuery.isFetching} + aria-label="Refresh risk events" + > + + + + Refresh + + } + filters={ + + } + status={ + resultsQuery.isFetching && results.length > 0 ? ( +
+
+
+ ) : null + } + header={ +
+ +
+ } + footer={ + results.length > 0 ? ( + resultsQuery.fetchNextPage()} + /> + ) : null + } + detail={ + !open && setSelectedChatId(null)} + direction="right" + > + + {selectedChatId && ( + setSelectedChatId(null)} + onDelete={() => setSelectedChatId(null)} + collapseNonRisk + /> + )} + + + } + scrollRef={containerRef} + onScroll={handleScroll} + surfaceClassName="overflow-x-auto" + contentClassName="min-w-[1120px]" + > + + + ); +} + +function RiskEventsHeader() { + return ( +
+
Timestamp
+
Category
+
Rule
+
Session Name
+
User
+
Match
+
Policy Note
+
Actions
+
+ ); +} + +function RiskEventsRows({ + error, + isLoading, + results, + policyMessageById, + isExcluding, + scrollRef, + onSelectChat, + onExclude, +}: { + error: Error | null; + isLoading: boolean; + results: RiskResult[]; + policyMessageById: Map; + isExcluding: boolean; + scrollRef: RefObject; + onSelectChat: (chatId: string | null) => void; + onExclude: (policyId: string, match: string, serverName?: string) => void; +}) { + const rowVirtualizer = useVirtualizer({ + count: results.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 52, + overscan: 12, + }); + + if (error) { + return ( +
+
+ +
+ + Error loading risk events + + + {error.message} + +
+ ); + } + + if (isLoading) { + return ( +
+ + Loading risk events... +
+ ); + } + + if (results.length === 0) { + return ( +
+
+ +
+ + No risk events found + + + Findings will appear here as messages are analyzed. + +
+ ); + } + + return ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const result = results[virtualRow.index]; + if (!result) return null; + + return ( +
+ +
+ ); + })} +
+ ); +} + +function RiskEventsRow({ + result, + policyNote, + isExcluding, + onSelectChat, + onExclude, +}: { + result: RiskResult; + policyNote: string | undefined; + isExcluding: boolean; + onSelectChat: (chatId: string | null) => void; + onExclude: (policyId: string, match: string, serverName?: string) => void; +}) { + const isShadowMCP = result.source === "shadow_mcp"; + + return ( +
{ + if (result.chatId) { + onSelectChat(result.chatId); + } + }} + onKeyDown={(e) => { + if (!result.chatId) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectChat(result.chatId); + } + }} + > +
+ {result.createdAt ? new Date(result.createdAt).toLocaleString() : "-"} +
+
+ +
+
+ +
+
{result.chatTitle ?? "Untitled"}
+
{result.userId ?? "-"}
+
+ {isShadowMCP && result.match ? ( + + {result.match} + + ) : ( + + )} +
+
+ {policyNote ?? "-"} +
+
+ {isShadowMCP && result.match ? ( + + ) : null} +
+
+ ); +} + +function RiskEventsFooter({ + count, + totalCount, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: { + count: number; + totalCount: number; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; +}) { + return ( +
+ + Showing {count.toLocaleString()} of {totalCount.toLocaleString()}{" "} + {totalCount === 1 ? "finding" : "findings"} + {hasNextPage && " - Scroll to load more"} + + {hasNextPage ? ( + + ) : null} +
+ ); +} diff --git a/client/dashboard/src/pages/security/SecurityOverview.tsx b/client/dashboard/src/pages/security/SecurityOverview.tsx index edda84478a..542587955d 100644 --- a/client/dashboard/src/pages/security/SecurityOverview.tsx +++ b/client/dashboard/src/pages/security/SecurityOverview.tsx @@ -1,3 +1,4 @@ +import { Button, Icon } from "@speakeasy-api/moonshine"; import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; import { Type } from "@/components/ui/type"; @@ -17,103 +18,22 @@ import { SelectValue, } from "@/components/ui/select"; import { useRoutes } from "@/routes"; -import { Eye, EyeOff, Shield, ShieldOff } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { useCallback, useMemo, useState } from "react"; +import { Shield, ShieldOff } from "lucide-react"; +import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { + invalidateAllRiskListResults, useRiskApproveShadowMCPMutation, useRiskListPolicies, invalidateAllRiskListShadowMCPApprovals, } from "@gram/client/react-query/index.js"; import { useSdkClient } from "@/contexts/Sdk"; import { toast } from "sonner"; -import { - DETECTION_RULES, - RULE_CATEGORY_META, - type RuleCategory, -} from "./policy-data"; -import { humanizeRuleId } from "./rule-ids"; import { ChatDetailPanel } from "@/pages/chatLogs/ChatDetailPanel"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { MetricCard } from "@/components/chart/MetricCard"; -import { Button as MoonshineButton, Icon } from "@speakeasy-api/moonshine"; - -// DETECTION_RULES.id is the canonical rule_id the backend writes to -// risk_results, so lookup maps key by it directly. -const RULE_ID_TO_CATEGORY = new Map(); -const RULE_ID_TO_TITLE = new Map(); -for (const [category, rules] of Object.entries(DETECTION_RULES)) { - for (const rule of rules) { - RULE_ID_TO_CATEGORY.set(rule.id, category as RuleCategory); - RULE_ID_TO_TITLE.set(rule.id, rule.title); - } -} - -const SOURCE_TO_CATEGORY = new Map([ - ["destructive_tool", "destructive_tool"], - ["shadow_mcp", "shadow_mcp"], - ["prompt_injection", "prompt_injection"], - ["cli_destructive", "cli_destructive"], -]); - -function getCategoryForFinding( - source: string | undefined, - ruleId: string | undefined, -): RuleCategory | null { - if (ruleId) { - const byRule = RULE_ID_TO_CATEGORY.get(ruleId); - if (byRule) return byRule; - } - if (source) { - return SOURCE_TO_CATEGORY.get(source) ?? null; - } - return null; -} - -function getRuleTitleFallback(ruleId: string | undefined): string { - if (!ruleId) return "-"; - return RULE_ID_TO_TITLE.get(ruleId) ?? humanizeRuleId(ruleId); -} - -function CategoryLabel({ - source, - ruleId, -}: { - source?: string; - ruleId?: string; -}) { - const category = getCategoryForFinding(source, ruleId); - if (category) { - return ( - - {RULE_CATEGORY_META[category].label} - - ); - } - // Unknown source: title-case it so the table cell still reads cleanly - // (e.g. a future "presidio_pro" source renders as "Presidio Pro"). - return ( - - {source ? humanizeRuleId(source.replace(/_/g, "-")) : "-"} - - ); -} - -// Renders a rule id with a tooltip-quality fallback when the dashboard -// hasn't seen this rule before. The backend may roll out new gitleaks, -// presidio, or prompt_injection rules independently of the dashboard, so -// every snake_case id needs to display legibly without a code change. -function RuleLabel({ ruleId }: { source?: string; ruleId?: string }) { - if (!ruleId) return -; - const title = getRuleTitleFallback(ruleId); - return ( - - {title} - - ); -} +import { CategoryLabel, MaskedMatch, RuleLabel } from "./risk-ui"; export default function SecurityOverview() { return ( @@ -130,48 +50,6 @@ export default function SecurityOverview() { ); } -function MaskedMatch({ value }: { value: string | undefined }) { - const [revealed, setRevealed] = useState(false); - - if (!value) return -; - - if (!revealed) { - return ( - - ); - } - - return ( - - - {value.length > 40 - ? `${value.slice(0, 20)}...${value.slice(-10)}` - : value} - - - - ); -} - function SecurityOverviewContent() { const routes = useRoutes(); const client = useSdkClient(); @@ -241,6 +119,7 @@ function SecurityOverviewContent() { queryClient.invalidateQueries({ queryKey: ["risk", "results", "list"], }); + invalidateAllRiskListResults(queryClient); invalidateAllRiskListShadowMCPApprovals(queryClient); }, onError: (err) => { @@ -315,15 +194,15 @@ function SecurityOverviewContent() { project - routes.policyCenter.goTo()} > - Manage Policies - + Manage Policies + - - + +
@@ -363,15 +242,15 @@ function SecurityOverviewContent() { - routes.policyCenter.goTo()} > - Manage Policies - + Manage Policies + - - + + @@ -439,7 +318,7 @@ function SecurityOverviewContent() { {chatSummaryQuery.hasNextPage && (
+ ); + } + + return ( + + + {value.length > 40 + ? `${value.slice(0, 20)}...${value.slice(-10)}` + : value} + + + + ); +} diff --git a/client/dashboard/src/pages/security/risk-utils.ts b/client/dashboard/src/pages/security/risk-utils.ts new file mode 100644 index 0000000000..647a69b76f --- /dev/null +++ b/client/dashboard/src/pages/security/risk-utils.ts @@ -0,0 +1,46 @@ +import { DETECTION_RULES, type RuleCategory } from "./policy-data"; +import { humanizeRuleId } from "./rule-ids"; + +const SOURCE_TO_CATEGORY: ReadonlyMap = new Map< + string, + RuleCategory +>([ + ["destructive_tool", "destructive_tool"], + ["shadow_mcp", "shadow_mcp"], + ["prompt_injection", "prompt_injection"], + ["cli_destructive", "cli_destructive"], +]); + +const ruleIdToCategory = new Map(); +const ruleIdToTitle = new Map(); + +for (const [category, rules] of Object.entries(DETECTION_RULES)) { + for (const rule of rules) { + ruleIdToCategory.set(rule.id, category as RuleCategory); + ruleIdToTitle.set(rule.id, rule.title); + } +} + +// DETECTION_RULES.id is the canonical rule_id the backend writes to +// risk_results, so lookup maps key by it directly. +const RULE_ID_TO_CATEGORY: ReadonlyMap = ruleIdToCategory; +const RULE_ID_TO_TITLE: ReadonlyMap = ruleIdToTitle; + +export function getRuleTitleFallback(ruleId: string | undefined): string { + if (!ruleId) return "-"; + return RULE_ID_TO_TITLE.get(ruleId) ?? humanizeRuleId(ruleId); +} + +export function getCategoryForFinding( + source?: string, + ruleId?: string, +): RuleCategory | null { + if (ruleId) { + const byRule = RULE_ID_TO_CATEGORY.get(ruleId); + if (byRule) return byRule; + } + if (source) { + return SOURCE_TO_CATEGORY.get(source) ?? null; + } + return null; +} diff --git a/client/dashboard/src/routes.tsx b/client/dashboard/src/routes.tsx index 4641ad1f9a..7f190437c7 100644 --- a/client/dashboard/src/routes.tsx +++ b/client/dashboard/src/routes.tsx @@ -30,7 +30,12 @@ import Home from "./pages/home/Home"; import Integrations from "./pages/integrations/Integrations"; import Login from "./pages/login/Login"; import Register from "./pages/login/Register"; -import { LogsRoot, LogsMCPPage, LogsToolsPage } from "./pages/logs/Logs"; +import { + LogsRoot, + LogsMCPPage, + LogsRiskEventsPage, + LogsToolsPage, +} from "./pages/logs/Logs"; import { BuiltInMCPDetailPage } from "./pages/mcp/BuiltInMCPDetailPage"; import { MCPDetailPage, MCPDetailsRoot } from "./pages/mcp/MCPDetails"; import { MCPPage, MCPRoot } from "./pages/mcp/MCP"; @@ -421,6 +426,11 @@ const ROUTE_STRUCTURE = { url: "mcp", component: LogsMCPPage, }, + riskEvents: { + title: "Risk Events", + url: "risk-events", + component: LogsRiskEventsPage, + }, agents: { title: "Agent Sessions", url: "agents",