diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 019cea35..483c7316 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ const Logs = lazy(() => import('./pages/Logs')) const Domains = lazy(() => import('./pages/Domains')) const Security = lazy(() => import('./pages/Security')) const AccessLists = lazy(() => import('./pages/AccessLists')) +const WafConfig = lazy(() => import('./pages/WafConfig')) const Uptime = lazy(() => import('./pages/Uptime')) const Notifications = lazy(() => import('./pages/Notifications')) const Login = lazy(() => import('./pages/Login')) @@ -56,7 +57,7 @@ export default function App() { } /> } /> } /> - WAF has no configuration at this time.} /> + } /> } /> } /> } /> diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts index aac768b5..71c56635 100644 --- a/frontend/src/api/security.ts +++ b/frontend/src/api/security.ts @@ -75,12 +75,40 @@ export const createDecision = async (payload: any) => { return response.data } -export const getRuleSets = async () => { - const response = await client.get('/security/rulesets') +// WAF Ruleset types +export interface SecurityRuleSet { + id: number + uuid: string + name: string + source_url: string + mode: string + last_updated: string + content: string +} + +export interface RuleSetsResponse { + rulesets: SecurityRuleSet[] +} + +export interface UpsertRuleSetPayload { + id?: number + name: string + content?: string + source_url?: string + mode?: 'blocking' | 'detection' +} + +export const getRuleSets = async (): Promise => { + const response = await client.get('/security/rulesets') return response.data } -export const upsertRuleSet = async (payload: any) => { +export const upsertRuleSet = async (payload: UpsertRuleSetPayload) => { const response = await client.post('/security/rulesets', payload) return response.data } + +export const deleteRuleSet = async (id: number) => { + const response = await client.delete(`/security/rulesets/${id}`) + return response.data +} diff --git a/frontend/src/hooks/useSecurity.ts b/frontend/src/hooks/useSecurity.ts index b7d44db5..6c1ff5d2 100644 --- a/frontend/src/hooks/useSecurity.ts +++ b/frontend/src/hooks/useSecurity.ts @@ -1,5 +1,18 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus, getDecisions, createDecision, getRuleSets, upsertRuleSet } from '../api/security' +import { + getSecurityStatus, + getSecurityConfig, + updateSecurityConfig, + generateBreakGlassToken, + enableCerberus, + disableCerberus, + getDecisions, + createDecision, + getRuleSets, + upsertRuleSet, + deleteRuleSet, + type UpsertRuleSetPayload, +} from '../api/security' import toast from 'react-hot-toast' export function useSecurityStatus() { @@ -48,8 +61,28 @@ export function useRuleSets() { export function useUpsertRuleSet() { const qc = useQueryClient() return useMutation({ - mutationFn: (payload: any) => upsertRuleSet(payload), - onSuccess: () => qc.invalidateQueries({ queryKey: ['securityRulesets'] }), + mutationFn: (payload: UpsertRuleSetPayload) => upsertRuleSet(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['securityRulesets'] }) + toast.success('Rule set saved successfully') + }, + onError: (err: Error) => { + toast.error(`Failed to save rule set: ${err.message}`) + }, + }) +} + +export function useDeleteRuleSet() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deleteRuleSet(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['securityRulesets'] }) + toast.success('Rule set deleted') + }, + onError: (err: Error) => { + toast.error(`Failed to delete rule set: ${err.message}`) + }, }) } diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index 4836dc22..0421db04 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -292,6 +292,16 @@ export default function Security() {

OWASP Core Rule Set

+
+ +
diff --git a/frontend/src/pages/WafConfig.tsx b/frontend/src/pages/WafConfig.tsx new file mode 100644 index 00000000..8d1a2c25 --- /dev/null +++ b/frontend/src/pages/WafConfig.tsx @@ -0,0 +1,435 @@ +import { useState } from 'react' +import { Shield, Plus, Pencil, Trash2, ExternalLink, FileCode2 } from 'lucide-react' +import { Button } from '../components/ui/Button' +import { Input } from '../components/ui/Input' +import { useRuleSets, useUpsertRuleSet, useDeleteRuleSet } from '../hooks/useSecurity' +import type { SecurityRuleSet, UpsertRuleSetPayload } from '../api/security' + +/** + * Confirmation dialog for destructive actions + */ +function ConfirmDialog({ + isOpen, + title, + message, + confirmLabel, + onConfirm, + onCancel, + isLoading, +}: { + isOpen: boolean + title: string + message: string + confirmLabel: string + onConfirm: () => void + onCancel: () => void + isLoading?: boolean +}) { + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ ) +} + +/** + * Form for creating/editing a WAF rule set + */ +function RuleSetForm({ + initialData, + onSubmit, + onCancel, + isLoading, +}: { + initialData?: SecurityRuleSet + onSubmit: (data: UpsertRuleSetPayload) => void + onCancel: () => void + isLoading?: boolean +}) { + const [name, setName] = useState(initialData?.name || '') + const [sourceUrl, setSourceUrl] = useState(initialData?.source_url || '') + const [content, setContent] = useState(initialData?.content || '') + const [mode, setMode] = useState<'blocking' | 'detection'>( + initialData?.mode === 'detection' ? 'detection' : 'blocking' + ) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ + id: initialData?.id, + name: name.trim(), + source_url: sourceUrl.trim() || undefined, + content: content.trim() || undefined, + mode, + }) + } + + const isValid = name.trim().length > 0 && (content.trim().length > 0 || sourceUrl.trim().length > 0) + + return ( +
+ setName(e.target.value)} + placeholder="e.g., OWASP CRS" + required + data-testid="ruleset-name-input" + /> + +
+ +
+ + +
+

+ {mode === 'blocking' + ? 'Malicious requests will be blocked with HTTP 403' + : 'Malicious requests will be logged but not blocked'} +

+
+ + setSourceUrl(e.target.value)} + placeholder="https://example.com/rules.conf" + helperText="URL to fetch rules from. Leave empty to use inline content." + data-testid="ruleset-url-input" + /> + +
+ +