diff --git a/.changeset/quick-create-llm-suggest.md b/.changeset/quick-create-llm-suggest.md new file mode 100644 index 0000000000..e6a69fb991 --- /dev/null +++ b/.changeset/quick-create-llm-suggest.md @@ -0,0 +1,7 @@ +--- +"server": minor +"dashboard": minor +"sdk": minor +--- + +Add `risk.customRules.suggest` endpoint that calls OpenRouter to turn a one-line description ("what do you want to detect?") into a prefilled custom detection rule. The dashboard's New Custom Detection Rule sheet now opens on a single textarea, calls the new endpoint, and lands the operator in the editable review form with the suggested rule_id, title, description, regex, and severity. diff --git a/.changeset/rule-playground-and-drop-severity.md b/.changeset/rule-playground-and-drop-severity.md new file mode 100644 index 0000000000..8c96822785 --- /dev/null +++ b/.changeset/rule-playground-and-drop-severity.md @@ -0,0 +1,9 @@ +--- +"server": minor +"dashboard": minor +"sdk": minor +--- + +Add a rule playground: from the Detection Rules detail sheet, the operator pastes a sample into a textarea and the dashboard calls the new `risk.rules.test` endpoint which dispatches to the same scanner code (gitleaks, Presidio, prompt-injection, regex) the worker uses. The response is a list of `TestDetectionRuleMatch`es mirroring the runtime risk_result shape. + +Drop the severity-override UI from the rule detail sheet. The override edit / reset affordances will return in a follow-up PR; default severity continues to render as a row badge for context. diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index 7676541ae7..157a164936 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -18463,6 +18463,113 @@ paths: x-speakeasy-name-override: list x-speakeasy-react-hook: name: RiskCategories + /rpc/risk.customRules.suggest: + post: + description: Suggest a custom detection rule (rule_id, title, description, regex, severity) from a natural-language prompt. Calls the configured LLM with a JSON-schema constrained response so the dashboard can prefill the create form. + operationId: suggestCustomDetectionRule + parameters: + - allowEmptyValue: true + description: API Key header + in: header + name: Gram-Key + schema: + description: API Key header + type: string + - allowEmptyValue: true + description: Session header + in: header + name: Gram-Session + schema: + description: Session header + type: string + - allowEmptyValue: true + description: project header + in: header + name: Gram-Project + schema: + description: project header + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestCustomDetectionRuleRequestBody' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestCustomDetectionRuleResult' + description: OK response. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'bad_request: request is invalid' + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unauthorized: unauthorized access' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'forbidden: permission denied' + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'not_found: resource not found' + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'conflict: resource already exists' + "415": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unsupported_media: unsupported media type' + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'invalid: request contains one or more invalidation fields' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unexpected: an unexpected error occurred' + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'gateway_error: an unexpected error occurred' + security: + - apikey_header_Gram-Key: [] + project_slug_header_Gram-Project: [] + - project_slug_header_Gram-Project: [] + session_header_Gram-Session: [] + - {} + summary: suggestCustomDetectionRule risk + tags: + - risk + x-speakeasy-group: risk.customRules + x-speakeasy-name-override: suggest + x-speakeasy-react-hook: + name: RiskSuggestCustomRule + type: mutation /rpc/risk.overview.get: get: description: Get risk overview metrics and trend data for the current project. @@ -19842,6 +19949,113 @@ paths: x-speakeasy-name-override: list x-speakeasy-react-hook: name: RiskListResults + /rpc/risk.rules.test: + post: + description: Run a single detection rule against pasted sample text and return any matches. Reuses the same scanner code (gitleaks, Presidio, prompt-injection, custom regex) that the analyzer runs in production so the playground match shape mirrors the chat-message path. + operationId: testDetectionRule + parameters: + - allowEmptyValue: true + description: API Key header + in: header + name: Gram-Key + schema: + description: API Key header + type: string + - allowEmptyValue: true + description: Session header + in: header + name: Gram-Session + schema: + description: Session header + type: string + - allowEmptyValue: true + description: project header + in: header + name: Gram-Project + schema: + description: project header + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TestDetectionRuleRequestBody' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/TestDetectionRuleResult' + description: OK response. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'bad_request: request is invalid' + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unauthorized: unauthorized access' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'forbidden: permission denied' + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'not_found: resource not found' + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'conflict: resource already exists' + "415": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unsupported_media: unsupported media type' + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'invalid: request contains one or more invalidation fields' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unexpected: an unexpected error occurred' + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'gateway_error: an unexpected error occurred' + security: + - apikey_header_Gram-Key: [] + project_slug_header_Gram-Project: [] + - project_slug_header_Gram-Project: [] + session_header_Gram-Session: [] + - {} + summary: testDetectionRule risk + tags: + - risk + x-speakeasy-group: risk.rules + x-speakeasy-name-override: test + x-speakeasy-react-hook: + name: RiskTestDetectionRule + type: mutation /rpc/slack-apps.configure: post: description: Store Slack credentials (client ID, client secret, signing secret) for an app. @@ -36319,6 +36533,51 @@ components: required: - id - feedback + SuggestCustomDetectionRuleRequestBody: + type: object + properties: + existing_rule_ids: + type: array + items: + type: string + description: Existing built-in and custom rule ids the suggested id must avoid colliding with. + prompt: + type: string + description: Natural-language description of what the rule should detect. + minLength: 3 + maxLength: 500 + required: + - prompt + SuggestCustomDetectionRuleResult: + type: object + properties: + description: + type: string + description: Description of what the rule detects and why it matters. + regex: + type: string + description: RE2-compatible regex pattern the rule should match against. + rule_id: + type: string + description: Suggested stable identifier, prefixed with `custom.`. + severity: + type: string + description: Suggested severity level. + enum: + - info + - low + - medium + - high + - critical + title: + type: string + description: Short, human-friendly title for the rule. + required: + - rule_id + - title + - description + - regex + - severity TelemetryFilter: type: object properties: @@ -36384,6 +36643,81 @@ components: - attributes - resource_attributes - service + TestDetectionRuleMatch: + type: object + properties: + confidence: + type: number + description: Confidence score in the range 0.0 to 1.0. + format: double + description: + type: string + description: Human-readable description of why this match was flagged. + end_pos: + type: integer + description: Exclusive end byte offset of the match in the sample. + format: int64 + match: + type: string + description: Matched substring of the sample. + rule_id: + type: string + description: Canonical rule id of the match (may differ from the requested rule id when one input matches multiple rules). + source: + type: string + description: Detection source (e.g. `gitleaks`, `presidio`, `prompt_injection`, `custom`). + start_pos: + type: integer + description: Inclusive start byte offset of the match in the sample. + format: int64 + tags: + type: array + items: + type: string + description: Tags from the underlying rule. + required: + - rule_id + - match + - start_pos + - end_pos + - source + - confidence + TestDetectionRuleRequestBody: + type: object + properties: + regex: + type: string + description: Regex pattern. Required for `custom.*` rule ids since the server doesn't persist custom rules yet; ignored for built-in rules. + rule_id: + type: string + description: Rule identifier to evaluate (e.g. `secret.aws_access_token`, `pii.email_address`, `custom.acme_token`). + minLength: 1 + maxLength: 200 + text: + type: string + description: Sample text to scan. + minLength: 1 + maxLength: 50000 + required: + - rule_id + - text + TestDetectionRuleResult: + type: object + properties: + matches: + type: array + items: + $ref: '#/components/schemas/TestDetectionRuleMatch' + description: Matches the rule found in the sample. + reason: + type: string + description: Why the rule isn't supported when `supported` is false. + supported: + type: boolean + description: False when the rule has no text-only detector (e.g. `shadow_mcp`, `destructive_tool`). + required: + - matches + - supported TierLimits: type: object properties: diff --git a/.speakeasy/workflow.lock b/.speakeasy/workflow.lock index ff601ca9f8..78ea118c22 100644 --- a/.speakeasy/workflow.lock +++ b/.speakeasy/workflow.lock @@ -13,8 +13,6 @@ targets: sourceNamespace: gram-api-description sourceRevisionDigest: sha256:4f5d0ec1263cc5417617a7cee7786490592d78f7f02371310ef59fe4a224f01f sourceBlobDigest: sha256:16a89de83ab7c177db0d5f4bf86f6f3fbcad0a7b69c026c8bb9dc71377230dd2 - codeSamplesNamespace: gram-api-description-typescript-code-samples - codeSamplesRevisionDigest: sha256:901f3d0135976214725cabf726813f14f2148dc9cf0938525f6216c163ced2bc workflow: workflowVersion: 1.0.0 speakeasyVersion: pinned diff --git a/client/dashboard/src/components/app-sidebar.tsx b/client/dashboard/src/components/app-sidebar.tsx index dffb603ebd..8e86e50246 100644 --- a/client/dashboard/src/components/app-sidebar.tsx +++ b/client/dashboard/src/components/app-sidebar.tsx @@ -88,9 +88,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const observeActive = [routes.insights, routes.logs].some((r) => r.active); - const securityActive = [routes.riskOverview, routes.policyCenter].some( - (r) => r.active, - ); + const securityActive = [ + routes.riskOverview, + routes.detectionRules, + routes.policyCenter, + ].some((r) => r.active); let activeGroup: string | undefined; switch (true) { @@ -123,6 +125,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { routes.insights, routes.logs, routes.riskOverview, + routes.detectionRules, routes.policyCenter, routes.settings, ]; @@ -207,6 +210,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { label="Secure" Icon={(p) => } defaultHref={routes.riskOverview.href()} + stage="beta" > ) { item={routes.policyCenter} scope={["project:read", "project:write"]} /> + {/* Settings — top-level, no group */} diff --git a/client/dashboard/src/components/nav-menu.tsx b/client/dashboard/src/components/nav-menu.tsx index 3b16d7e452..e50ab10764 100644 --- a/client/dashboard/src/components/nav-menu.tsx +++ b/client/dashboard/src/components/nav-menu.tsx @@ -450,11 +450,13 @@ export function CollapsibleNavGroup({ label, Icon, defaultHref, + stage, children, }: { label: string; Icon: React.ComponentType<{ className?: string }>; defaultHref?: string; + stage?: ReleaseStage; children: React.ReactNode; }) { const { openGroups, toggleGroup, openGroup } = @@ -498,6 +500,12 @@ export function CollapsibleNavGroup({ {label} + {stage && ( + + )} diff --git a/client/dashboard/src/pages/security/DetectionRules.tsx b/client/dashboard/src/pages/security/DetectionRules.tsx new file mode 100644 index 0000000000..ff2afc7d41 --- /dev/null +++ b/client/dashboard/src/pages/security/DetectionRules.tsx @@ -0,0 +1,1324 @@ +import { Page } from "@/components/page-layout"; +import { RequireScope } from "@/components/require-scope"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { TextArea } from "@/components/ui/textarea"; +import { Type } from "@/components/ui/type"; +import { cn } from "@/lib/utils"; +import { Icon, type IconName } from "@speakeasy-api/moonshine"; +import { + ArrowLeft, + ChevronRight, + Loader2, + Plus, + Sparkles, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + useListChats, + useRiskSuggestCustomRuleMutation, + useRiskTestDetectionRuleMutation, +} from "@gram/client/react-query/index.js"; +import type { ChatOverview } from "@gram/client/models/components/chatoverview.js"; +import type { TestDetectionRuleMatch } from "@gram/client/models/components/testdetectionrulematch.js"; +import type { TestDetectionRuleResult } from "@gram/client/models/components/testdetectionruleresult.js"; +import { chatLoad } from "@gram/client/funcs/chatLoad.js"; +import { unwrapAsync } from "@gram/client/types/fp.js"; +import { useSdkClient } from "@/contexts/Sdk"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + BUILTIN_RULES_BY_CATEGORY, + SEVERITY_LEVELS, + SEVERITY_META, + useDetectionRulesStore, + validateCustomRuleId, + validateRegex, + type BuiltinRule, + type CustomDetectionRule, + type SeverityLevel, +} from "./detection-rules-data"; +import { RULE_CATEGORY_META, type RuleCategory } from "./policy-data"; + +/** Presidio-backed categories: kept in the same order the policy form uses + * so users see the two surfaces in the same shape. */ +const PRESIDIO_CATEGORIES: RuleCategory[] = [ + "financial", + "pii", + "government_ids", + "healthcare", +]; + +const BUILTIN_CATEGORY_ORDER: RuleCategory[] = [ + "secrets", + ...PRESIDIO_CATEGORIES, + "shadow_mcp", + "destructive_tool", + "prompt_injection", +]; + +type SelectedRule = + | { kind: "builtin"; rule: BuiltinRule } + | { kind: "custom"; rule: CustomDetectionRule }; + +export default function DetectionRules() { + return ( + + + + + + + + + + + ); +} + +function DetectionRulesContent() { + const { customRules, addCustomRule, updateCustomRule, removeCustomRule } = + useDetectionRulesStore(); + + const [createOpen, setCreateOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [expanded, setExpanded] = useState( + null, + ); + + return ( + <> + + Detection Rules + + Built-in detection rules grouped by category. Click a rule to view its + description and try it against pasted text, or add your own custom + regex rule. + + + + + +
+ {customRules.length > 0 && ( + + setExpanded(expanded === "custom" ? null : "custom") + } + onSelect={(rule) => setSelected({ kind: "custom", rule })} + /> + )} + + setExpanded(expanded === cat ? null : cat)} + onSelect={(rule) => setSelected({ kind: "builtin", rule })} + /> +
+
+
+ + setSelected(null)} + onUpdateCustomRule={updateCustomRule} + onDeleteCustomRule={(id) => { + removeCustomRule(id); + setSelected(null); + toast.success("Custom detection rule deleted"); + }} + /> + + r.id)} + onCreate={(rule) => { + addCustomRule(rule); + setCreateOpen(false); + toast.success(`Created custom rule ${rule.id}`); + }} + /> + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Custom rules section */ +/* -------------------------------------------------------------------------- */ + +function CustomRulesSection({ + rules, + expanded, + onToggle, + onSelect, +}: { + rules: CustomDetectionRule[]; + expanded: boolean; + onToggle: () => void; + onSelect: (rule: CustomDetectionRule) => void; +}) { + const meta = RULE_CATEGORY_META.custom; + return ( +
+ + Custom + +
+ + {expanded && ( +
+ {rules.map((rule) => ( + onSelect(rule)} + /> + ))} +
+ )} +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Builtin rules section */ +/* -------------------------------------------------------------------------- */ + +function BuiltinRulesSection({ + expanded, + onToggle, + onSelect, +}: { + expanded: RuleCategory | "custom" | null; + onToggle: (cat: RuleCategory) => void; + onSelect: (rule: BuiltinRule) => void; +}) { + return ( +
+ + Built-in + +
+ {BUILTIN_CATEGORY_ORDER.map((cat) => { + const meta = RULE_CATEGORY_META[cat]; + const rules = BUILTIN_RULES_BY_CATEGORY[cat]; + const isExpanded = expanded === cat; + return ( +
+ onToggle(cat)} + count={rules.length} + /> + {isExpanded && rules.length > 0 && ( +
+ {rules.map((rule) => ( + onSelect(rule)} + /> + ))} +
+ )} +
+ ); + })} +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Shared row components */ +/* -------------------------------------------------------------------------- */ + +function CategoryHeader({ + icon, + label, + description, + expanded, + count, + onClick, +}: { + icon: IconName; + label: string; + description: string; + expanded: boolean; + count: number; + onClick: () => void; +}) { + return ( + + ); +} + +function RuleRow({ + title, + subtitle, + severity, + onClick, +}: { + title: string; + subtitle: string; + severity: SeverityLevel; + onClick: () => void; +}) { + return ( + + ); +} + +export function SeverityBadge({ severity }: { severity: SeverityLevel }) { + const meta = SEVERITY_META[severity]; + return ( + + {meta.label} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Rule detail sheet */ +/* -------------------------------------------------------------------------- */ + +function RuleDetailSheet({ + selection, + onClose, + onUpdateCustomRule, + onDeleteCustomRule, +}: { + selection: SelectedRule | null; + onClose: () => void; + onUpdateCustomRule: ( + id: string, + patch: Partial>, + ) => void; + onDeleteCustomRule: (id: string) => void; +}) { + return ( + !open && onClose()}> + + {selection?.kind === "builtin" && ( + + )} + {selection?.kind === "custom" && ( + onUpdateCustomRule(selection.rule.id, patch)} + onDelete={() => onDeleteCustomRule(selection.rule.id)} + /> + )} + + + ); +} + +function BuiltinRuleDetail({ rule }: { rule: BuiltinRule }) { + const meta = RULE_CATEGORY_META[rule.category]; + return ( + <> + + {rule.title} + + {rule.id} + + +
+ +
+ + {meta.label} +
+
+ + +

{rule.description}

+
+ + +
+ + ); +} + +function CustomRuleDetail({ + rule, + onUpdate, + onDelete, +}: { + rule: CustomDetectionRule; + onUpdate: ( + patch: Partial>, + ) => void; + onDelete: () => void; +}) { + const [title, setTitle] = useState(rule.title); + const [description, setDescription] = useState(rule.description); + const [regex, setRegex] = useState(rule.regex); + const [severity, setSeverity] = useState(rule.severity); + + const regexError = useMemo(() => validateRegex(regex), [regex]); + const dirty = + title !== rule.title || + description !== rule.description || + regex !== rule.regex || + severity !== rule.severity; + + return ( + <> + + {rule.title || rule.id} + + {rule.id} + + +
+
+ + +
+ +
+ +