743 lines
29 KiB
TypeScript
743 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
|
import { useFormState } from "react-dom";
|
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Chip,
|
|
Collapse,
|
|
Divider,
|
|
Drawer,
|
|
FormControlLabel,
|
|
IconButton,
|
|
Snackbar,
|
|
Stack,
|
|
Switch,
|
|
Tab,
|
|
Tabs,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
} from "@mui/material";
|
|
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import BlockIcon from "@mui/icons-material/Block";
|
|
import DeleteIcon from "@mui/icons-material/Delete";
|
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import { DataTable } from "@/src/components/ui/DataTable";
|
|
import type { WafEvent } from "@/src/lib/models/waf-events";
|
|
import type { WafSettings } from "@/src/lib/settings";
|
|
import {
|
|
suppressWafRuleGloballyAction,
|
|
suppressWafRuleForHostAction,
|
|
removeWafRuleGloballyAction,
|
|
lookupWafRuleMessageAction,
|
|
updateWafSettingsAction,
|
|
} from "../settings/actions";
|
|
|
|
type Props = {
|
|
events: WafEvent[];
|
|
pagination: { total: number; page: number; perPage: number };
|
|
initialSearch: string;
|
|
globalExcluded: number[];
|
|
globalExcludedMessages: Record<number, string | null>;
|
|
globalWafEnabled: boolean;
|
|
hostWafMap: Record<string, number[]>;
|
|
globalWaf: WafSettings | null;
|
|
};
|
|
|
|
const SEVERITY_COLOR: Record<string, "error" | "warning" | "info" | "default"> = {
|
|
CRITICAL: "error",
|
|
ERROR: "error",
|
|
HIGH: "error",
|
|
WARNING: "warning",
|
|
NOTICE: "info",
|
|
INFO: "info",
|
|
};
|
|
|
|
function SeverityChip({ severity }: { severity: string | null }) {
|
|
if (!severity) return <Typography variant="body2" color="text.disabled">—</Typography>;
|
|
const upper = severity.toUpperCase();
|
|
const color = SEVERITY_COLOR[upper] ?? "default";
|
|
return <Chip label={upper} size="small" color={color} variant="outlined" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />;
|
|
}
|
|
|
|
function BlockedChip({ blocked }: { blocked: boolean }) {
|
|
return blocked
|
|
? <Chip label="Blocked" size="small" color="error" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />
|
|
: <Chip label="Detected" size="small" color="warning" variant="outlined" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />;
|
|
}
|
|
|
|
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
|
{label}
|
|
</Typography>
|
|
<Box mt={0.25}>{children}</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function WafEventDrawer({
|
|
event,
|
|
onClose,
|
|
globalExcluded,
|
|
hostWafMap,
|
|
onSuppressGlobal,
|
|
onSuppressHost,
|
|
}: {
|
|
event: WafEvent | null;
|
|
onClose: () => void;
|
|
globalExcluded: number[];
|
|
hostWafMap: Record<string, number[]>;
|
|
onSuppressGlobal: (ruleId: number) => void;
|
|
onSuppressHost: (ruleId: number, host: string) => void;
|
|
}) {
|
|
const [pending, startTransition] = useTransition();
|
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; success: boolean }>({ open: false, message: "", success: true });
|
|
|
|
let parsedRaw: unknown = null;
|
|
if (event?.rawData) {
|
|
try { parsedRaw = JSON.parse(event.rawData); } catch { parsedRaw = event.rawData; }
|
|
}
|
|
|
|
const isGloballySuppressed = event?.ruleId != null && globalExcluded.includes(event.ruleId);
|
|
const isHostOnlySuppressed = event?.ruleId != null && !!event.host && (hostWafMap[event.host] ?? []).includes(event.ruleId);
|
|
const isHostSuppressed = isGloballySuppressed || isHostOnlySuppressed;
|
|
|
|
function handleSuppressGlobally() {
|
|
if (!event?.ruleId) return;
|
|
startTransition(async () => {
|
|
const result = await suppressWafRuleGloballyAction(event.ruleId!);
|
|
setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success });
|
|
if (result.success) onSuppressGlobal(event.ruleId!);
|
|
});
|
|
}
|
|
|
|
function handleSuppressForHost() {
|
|
if (!event?.ruleId || !event?.host) return;
|
|
startTransition(async () => {
|
|
const result = await suppressWafRuleForHostAction(event.ruleId!, event.host!);
|
|
setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success });
|
|
if (result.success) onSuppressHost(event.ruleId!, event.host!);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Drawer anchor="right" open={!!event} onClose={onClose} PaperProps={{ sx: { width: { xs: "100%", sm: 520 }, p: 3 } }}>
|
|
{event && (
|
|
<Stack spacing={2.5} sx={{ height: "100%", overflow: "auto" }}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
|
<Stack direction="row" alignItems="center" spacing={1}>
|
|
<BlockedChip blocked={event.blocked} />
|
|
<SeverityChip severity={event.severity} />
|
|
<Typography variant="h6" fontWeight={600}>WAF Event</Typography>
|
|
</Stack>
|
|
<IconButton onClick={onClose} size="small"><CloseIcon /></IconButton>
|
|
</Stack>
|
|
|
|
<Divider />
|
|
|
|
<DetailRow label="Time">
|
|
<Typography variant="body2">{new Date(event.ts * 1000).toLocaleString()}</Typography>
|
|
</DetailRow>
|
|
|
|
<DetailRow label="Host">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace", wordBreak: "break-all" }}>{event.host || "—"}</Typography>
|
|
</DetailRow>
|
|
|
|
<DetailRow label="Client IP">
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{event.clientIp}</Typography>
|
|
{event.countryCode && <Chip label={event.countryCode} size="small" variant="outlined" sx={{ height: 18, fontSize: "0.65rem" }} />}
|
|
</Stack>
|
|
</DetailRow>
|
|
|
|
<DetailRow label="Request">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace", wordBreak: "break-all" }}>
|
|
{event.method} {event.uri}
|
|
</Typography>
|
|
</DetailRow>
|
|
|
|
<DetailRow label="Rule ID">
|
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{event.ruleId ?? "—"}</Typography>
|
|
{event.ruleId != null && (
|
|
<>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="error"
|
|
startIcon={<BlockIcon fontSize="small" />}
|
|
onClick={handleSuppressGlobally}
|
|
disabled={pending || isGloballySuppressed}
|
|
sx={{ fontSize: "0.72rem", textTransform: "none" }}
|
|
>
|
|
{isGloballySuppressed ? "Suppressed Globally" : "Suppress Globally"}
|
|
</Button>
|
|
{event.host && (
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="warning"
|
|
startIcon={<BlockIcon fontSize="small" />}
|
|
onClick={handleSuppressForHost}
|
|
disabled={pending || isHostSuppressed}
|
|
sx={{ fontSize: "0.72rem", textTransform: "none" }}
|
|
>
|
|
{isHostSuppressed ? `Suppressed for ${event.host}` : `Suppress for ${event.host}`}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</DetailRow>
|
|
|
|
<DetailRow label="Rule Message">
|
|
<Typography variant="body2" sx={{ wordBreak: "break-word" }}>{event.ruleMessage ?? "—"}</Typography>
|
|
</DetailRow>
|
|
|
|
<Divider />
|
|
|
|
<DetailRow label="Raw Audit Data">
|
|
{parsedRaw !== null ? (
|
|
<Box
|
|
component="pre"
|
|
sx={{
|
|
m: 0, p: 1.5, borderRadius: 1, bgcolor: "action.hover",
|
|
fontSize: "0.7rem", fontFamily: "monospace", overflowX: "auto",
|
|
whiteSpace: "pre-wrap", wordBreak: "break-all", userSelect: "text",
|
|
}}
|
|
>
|
|
{JSON.stringify(parsedRaw, null, 2)}
|
|
</Box>
|
|
) : (
|
|
<Typography variant="body2" color="text.disabled">—</Typography>
|
|
)}
|
|
</DetailRow>
|
|
</Stack>
|
|
)}
|
|
</Drawer>
|
|
<Snackbar
|
|
open={snackbar.open}
|
|
autoHideDuration={4000}
|
|
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
|
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
|
>
|
|
<Alert severity={snackbar.success ? "success" : "error"} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
|
|
{snackbar.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function GlobalSuppressedRules({
|
|
excluded,
|
|
messages: initialMessages,
|
|
wafEnabled,
|
|
onRemove,
|
|
onAdd,
|
|
}: {
|
|
excluded: number[];
|
|
messages: Record<number, string | null>;
|
|
wafEnabled: boolean;
|
|
onRemove: (ruleId: number) => void;
|
|
onAdd: (ruleId: number, message: string | null) => void;
|
|
}) {
|
|
const [pending, startTransition] = useTransition();
|
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; success: boolean }>({ open: false, message: "", success: true });
|
|
const [messages, setMessages] = useState(initialMessages);
|
|
|
|
// Add-rule state
|
|
const [addInput, setAddInput] = useState("");
|
|
const [lookupPending, setLookupPending] = useState(false);
|
|
const [pendingRule, setPendingRule] = useState<{ id: number; message: string | null } | null>(null);
|
|
|
|
// Search
|
|
const [search, setSearch] = useState("");
|
|
|
|
function handleRemove(ruleId: number) {
|
|
startTransition(async () => {
|
|
const result = await removeWafRuleGloballyAction(ruleId);
|
|
setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success });
|
|
if (result.success) onRemove(ruleId);
|
|
});
|
|
}
|
|
|
|
async function handleLookup() {
|
|
const n = parseInt(addInput.trim(), 10);
|
|
if (!Number.isInteger(n) || n <= 0) return;
|
|
if (excluded.includes(n)) {
|
|
setSnackbar({ open: true, message: `Rule ${n} is already suppressed.`, success: false });
|
|
return;
|
|
}
|
|
setLookupPending(true);
|
|
try {
|
|
const result = await lookupWafRuleMessageAction(n);
|
|
setPendingRule({ id: n, message: result.message });
|
|
} finally {
|
|
setLookupPending(false);
|
|
}
|
|
}
|
|
|
|
function handleConfirmAdd() {
|
|
if (!pendingRule) return;
|
|
startTransition(async () => {
|
|
const result = await suppressWafRuleGloballyAction(pendingRule.id);
|
|
setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success });
|
|
if (result.success) {
|
|
onAdd(pendingRule.id, pendingRule.message);
|
|
setMessages((prev) => ({ ...prev, [pendingRule.id]: pendingRule.message }));
|
|
setAddInput("");
|
|
setPendingRule(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
const filtered = excluded.filter((id) => {
|
|
if (!search.trim()) return true;
|
|
const q = search.toLowerCase();
|
|
return (
|
|
String(id).includes(q) ||
|
|
(messages[id] ?? "").toLowerCase().includes(q)
|
|
);
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Stack spacing={2}>
|
|
<Box>
|
|
<Typography variant="h6" fontWeight={600}>Global WAF Rule Exclusions</Typography>
|
|
<Typography variant="body2" color="text.secondary" mt={0.5}>
|
|
Rules listed here are suppressed globally via <code>SecRuleRemoveById</code> for all proxy hosts using global WAF settings.
|
|
</Typography>
|
|
{!wafEnabled && (
|
|
<Alert severity="warning" sx={{ mt: 1.5 }}>Global WAF is currently disabled. Exclusions are saved but have no effect until WAF is enabled.</Alert>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Add rule */}
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
|
Add Rule by ID
|
|
</Typography>
|
|
<Stack direction="row" spacing={1} alignItems="center" mt={0.75} sx={{ maxWidth: 320 }}>
|
|
<TextField
|
|
size="small"
|
|
label="Rule ID"
|
|
value={addInput}
|
|
onChange={(e) => { setAddInput(e.target.value); setPendingRule(null); }}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleLookup(); } }}
|
|
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
|
sx={{ flex: 1 }}
|
|
disabled={lookupPending || pending}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={handleLookup}
|
|
disabled={!addInput.trim() || lookupPending || pending}
|
|
>
|
|
{lookupPending ? "Looking up…" : "Look up"}
|
|
</Button>
|
|
</Stack>
|
|
|
|
{pendingRule && (
|
|
<Box
|
|
sx={{
|
|
mt: 1.5, px: 2, py: 1.5, borderRadius: 1.5,
|
|
border: "1px solid", borderColor: "warning.main", bgcolor: "action.hover",
|
|
maxWidth: 480,
|
|
}}
|
|
>
|
|
<Typography variant="body2" fontFamily="monospace" fontWeight={700} color="error.light">
|
|
Rule {pendingRule.id}
|
|
</Typography>
|
|
<Typography variant="caption" color={pendingRule.message ? "text.secondary" : "text.disabled"} sx={{ display: "block", mt: 0.25 }}>
|
|
{pendingRule.message ?? "No description available — rule has not triggered yet"}
|
|
</Typography>
|
|
<Stack direction="row" spacing={1} mt={1.5}>
|
|
<Button size="small" variant="contained" color="error" onClick={handleConfirmAdd} disabled={pending}>
|
|
{pending ? "Suppressing…" : "Suppress Globally"}
|
|
</Button>
|
|
<Button size="small" variant="outlined" onClick={() => { setPendingRule(null); setAddInput(""); }} disabled={pending}>
|
|
Cancel
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Search */}
|
|
{excluded.length > 0 && (
|
|
<TextField
|
|
placeholder="Search by rule ID or message…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
size="small"
|
|
sx={{ maxWidth: 400 }}
|
|
slotProps={{
|
|
input: { startAdornment: <SearchIcon sx={{ mr: 1, color: "text.disabled", fontSize: 18 }} /> },
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{excluded.length === 0 ? (
|
|
<Box
|
|
sx={{
|
|
py: 6, textAlign: "center", color: "text.secondary",
|
|
border: "1px dashed", borderColor: "divider", borderRadius: 2,
|
|
}}
|
|
>
|
|
<BlockIcon sx={{ fontSize: 36, opacity: 0.3, mb: 1, display: "block", mx: "auto" }} />
|
|
<Typography variant="body2">No globally suppressed rules.</Typography>
|
|
<Typography variant="caption">Add a rule above or open a WAF event and click "Suppress Globally".</Typography>
|
|
</Box>
|
|
) : filtered.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>No rules match your search.</Typography>
|
|
) : (
|
|
<Stack spacing={1}>
|
|
{filtered.map((id) => (
|
|
<Box
|
|
key={id}
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
px: 2,
|
|
py: 1.5,
|
|
borderRadius: 1.5,
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
bgcolor: "action.hover",
|
|
}}
|
|
>
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
<Typography variant="body2" fontFamily="monospace" fontWeight={700} color="error.light">
|
|
Rule {id}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
color={messages[id] ? "text.secondary" : "text.disabled"}
|
|
sx={{ display: "block", mt: 0.25 }}
|
|
>
|
|
{messages[id] ?? "No description available — rule has not triggered yet"}
|
|
</Typography>
|
|
</Box>
|
|
<Tooltip title="Remove suppression">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleRemove(id)}
|
|
disabled={pending}
|
|
color="error"
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
<Snackbar
|
|
open={snackbar.open}
|
|
autoHideDuration={4000}
|
|
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
|
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
|
>
|
|
<Alert severity={snackbar.success ? "success" : "error"} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
|
|
{snackbar.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function WafEventsClient({ events, pagination, initialSearch, globalExcluded, globalExcludedMessages, globalWafEnabled, hostWafMap, globalWaf }: Props) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [tab, setTab] = useState(0);
|
|
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
|
const [selected, setSelected] = useState<WafEvent | null>(null);
|
|
const [localGlobalExcluded, setLocalGlobalExcluded] = useState(globalExcluded);
|
|
const [localGlobalMessages, setLocalGlobalMessages] = useState(globalExcludedMessages);
|
|
const [localHostWafMap, setLocalHostWafMap] = useState(hostWafMap);
|
|
const [wafState, wafFormAction] = useFormState(updateWafSettingsAction, null);
|
|
const [wafCustomDirectives, setWafCustomDirectives] = useState(globalWaf?.custom_directives ?? "");
|
|
const [wafShowTemplates, setWafShowTemplates] = useState(false);
|
|
useEffect(() => { setSearchTerm(initialSearch); }, [initialSearch]);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const updateSearch = useCallback(
|
|
(value: string) => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (value.trim()) {
|
|
params.set("search", value.trim());
|
|
} else {
|
|
params.delete("search");
|
|
}
|
|
params.delete("page");
|
|
router.push(`${pathname}?${params.toString()}`);
|
|
}, 400);
|
|
},
|
|
[router, pathname, searchParams]
|
|
);
|
|
|
|
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
|
|
|
|
const mobileCard = (event: WafEvent) => (
|
|
<Card
|
|
variant="outlined"
|
|
sx={{ p: 2, cursor: "pointer", "&:hover": { bgcolor: "action.hover" } }}
|
|
onClick={() => setSelected(event)}
|
|
>
|
|
<Stack spacing={1}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
<Stack direction="row" spacing={0.5}>
|
|
<BlockedChip blocked={event.blocked} />
|
|
<SeverityChip severity={event.severity} />
|
|
</Stack>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{new Date(event.ts * 1000).toLocaleString()}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.75rem", wordBreak: "break-all" }} color="text.secondary">
|
|
{event.host || "—"}
|
|
</Typography>
|
|
{event.ruleId && (
|
|
<Typography variant="caption" color="text.disabled">
|
|
Rule #{event.ruleId}
|
|
</Typography>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
|
|
const columns = [
|
|
{
|
|
id: "ts", label: "Time", width: 150,
|
|
render: (r: WafEvent) => (
|
|
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
|
{new Date(r.ts * 1000).toLocaleString()}
|
|
</Typography>
|
|
),
|
|
},
|
|
{
|
|
id: "blocked", label: "Action", width: 90,
|
|
render: (r: WafEvent) => <BlockedChip blocked={r.blocked} />,
|
|
},
|
|
{
|
|
id: "severity", label: "Severity", width: 100,
|
|
render: (r: WafEvent) => <SeverityChip severity={r.severity} />,
|
|
},
|
|
{
|
|
id: "host", label: "Host", width: 150,
|
|
render: (r: WafEvent) => (
|
|
<Tooltip title={r.host ?? ""} placement="top">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem", maxWidth: 150, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{r.host || <span style={{ opacity: 0.4 }}>—</span>}
|
|
</Typography>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
id: "clientIp", label: "Client IP", width: 140,
|
|
render: (r: WafEvent) => (
|
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
|
{r.clientIp}
|
|
</Typography>
|
|
{r.countryCode && (
|
|
<Chip label={r.countryCode} size="small" variant="outlined" sx={{ height: 18, fontSize: "0.65rem" }} />
|
|
)}
|
|
</Stack>
|
|
),
|
|
},
|
|
{
|
|
id: "method", label: "M", width: 60,
|
|
render: (r: WafEvent) => (
|
|
<Chip label={r.method || "—"} size="small" variant="outlined" sx={{ fontFamily: "monospace", fontSize: "0.7rem" }} />
|
|
),
|
|
},
|
|
{
|
|
id: "uri", label: "URI", width: 200,
|
|
render: (r: WafEvent) => (
|
|
<Tooltip title={r.uri} placement="top">
|
|
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem", maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{r.uri || <span style={{ opacity: 0.4 }}>—</span>}
|
|
</Typography>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
id: "ruleId", label: "Rule ID", width: 80,
|
|
render: (r: WafEvent) => (
|
|
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
|
{r.ruleId ?? "—"}
|
|
</Typography>
|
|
),
|
|
},
|
|
{
|
|
id: "ruleMessage", label: "Rule Message",
|
|
render: (r: WafEvent) => (
|
|
<Tooltip title={r.ruleMessage ?? ""} placement="top">
|
|
<Typography variant="body2" sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
{r.ruleMessage ?? <span style={{ opacity: 0.4 }}>—</span>}
|
|
</Typography>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Stack spacing={2} sx={{ width: "100%" }}>
|
|
<Typography variant="h4" fontWeight={600}>WAF</Typography>
|
|
<Typography color="text.secondary">
|
|
Web Application Firewall events and rule management.
|
|
</Typography>
|
|
|
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}>
|
|
<Tab label="Events" />
|
|
<Tab label="Suppressed Rules" />
|
|
<Tab label="Settings" />
|
|
</Tabs>
|
|
|
|
{tab === 0 && (
|
|
<>
|
|
<TextField
|
|
placeholder="Search by host, IP, URI, or rule message..."
|
|
value={searchTerm}
|
|
onChange={(e) => { setSearchTerm(e.target.value); updateSearch(e.target.value); }}
|
|
slotProps={{
|
|
input: { startAdornment: <SearchIcon sx={{ mr: 1, color: "rgba(255,255,255,0.5)" }} /> },
|
|
}}
|
|
size="small"
|
|
sx={{ maxWidth: 480 }}
|
|
/>
|
|
<DataTable
|
|
columns={columns}
|
|
data={events}
|
|
keyField="id"
|
|
emptyMessage="No WAF events found. Enable the WAF in Settings and send some traffic to see events here."
|
|
pagination={pagination}
|
|
onRowClick={setSelected}
|
|
mobileCard={mobileCard}
|
|
/>
|
|
<WafEventDrawer
|
|
event={selected}
|
|
onClose={() => setSelected(null)}
|
|
globalExcluded={localGlobalExcluded}
|
|
hostWafMap={localHostWafMap}
|
|
onSuppressGlobal={(ruleId) => setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])])}
|
|
onSuppressHost={(ruleId, host) => setLocalHostWafMap((prev) => ({ ...prev, [host]: [...new Set([...(prev[host] ?? []), ruleId])] }))}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{tab === 1 && (
|
|
<GlobalSuppressedRules
|
|
excluded={localGlobalExcluded}
|
|
messages={localGlobalMessages}
|
|
wafEnabled={globalWafEnabled}
|
|
onRemove={(ruleId) => setLocalGlobalExcluded((prev) => prev.filter((id) => id !== ruleId))}
|
|
onAdd={(ruleId, message) => {
|
|
setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])]);
|
|
setLocalGlobalMessages((prev) => ({ ...prev, [ruleId]: message }));
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{tab === 2 && (
|
|
<Stack spacing={3} sx={{ maxWidth: 720 }}>
|
|
<Box>
|
|
<Typography variant="h6" fontWeight={600}>WAF Settings</Typography>
|
|
<Typography variant="body2" color="text.secondary" mt={0.5}>
|
|
Configure the global Web Application Firewall. Per-host settings can merge with or override these defaults.
|
|
Powered by <strong>Coraza</strong> with optional OWASP Core Rule Set.
|
|
</Typography>
|
|
</Box>
|
|
<Stack component="form" action={wafFormAction} spacing={2}>
|
|
{wafState?.message && (
|
|
<Alert severity={wafState.success ? "success" : "error"}>{wafState.message}</Alert>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Switch name="waf_enabled" defaultChecked={globalWaf?.enabled ?? false} />}
|
|
label="Enable WAF globally (blocking)"
|
|
/>
|
|
<FormControlLabel
|
|
control={<Checkbox name="waf_load_owasp_crs" defaultChecked={globalWaf?.load_owasp_crs ?? true} />}
|
|
label={
|
|
<span>Load OWASP Core Rule Set{" "}
|
|
<Typography component="span" variant="caption" color="text.secondary">
|
|
(covers SQLi, XSS, LFI, RCE — recommended)
|
|
</Typography>
|
|
</span>
|
|
}
|
|
/>
|
|
{/* WafRuleExclusions intentionally omitted — managed in Suppressed Rules tab */}
|
|
<TextField
|
|
name="waf_custom_directives"
|
|
label="Custom SecLang Directives"
|
|
multiline minRows={3} maxRows={12}
|
|
value={wafCustomDirectives}
|
|
onChange={(e) => setWafCustomDirectives(e.target.value)}
|
|
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
|
|
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
|
helperText="ModSecurity SecLang syntax. Applied after OWASP CRS if enabled."
|
|
fullWidth
|
|
/>
|
|
<Box>
|
|
<Button
|
|
size="small"
|
|
endIcon={<ExpandMoreIcon sx={{ transform: wafShowTemplates ? "rotate(180deg)" : "none", transition: "transform 0.2s" }} />}
|
|
onClick={() => setWafShowTemplates((v) => !v)}
|
|
sx={{ color: "text.secondary", textTransform: "none", px: 0 }}
|
|
>
|
|
Quick Templates
|
|
</Button>
|
|
<Collapse in={wafShowTemplates}>
|
|
<Stack spacing={0.75} mt={0.75}>
|
|
{[
|
|
{ label: "Allow IP", snippet: `SecRule REMOTE_ADDR "@ipMatch 1.2.3.4" "id:9000,phase:1,allow,nolog,msg:'Allow IP'"` },
|
|
{ label: "Disable WAF for path", snippet: `SecRule REQUEST_URI "@beginsWith /api/" "id:9001,phase:1,ctl:ruleEngine=Off,nolog"` },
|
|
{ label: "Remove XSS rules", snippet: `SecRuleRemoveByTag "attack-xss"` },
|
|
{ label: "Block User-Agent", snippet: `SecRule REQUEST_HEADERS:User-Agent "@contains badbot" "id:9002,phase:1,deny,status:403,log"` },
|
|
].map((t) => (
|
|
<Button key={t.label} size="small" variant="outlined"
|
|
startIcon={<ContentCopyIcon fontSize="inherit" />}
|
|
onClick={() => setWafCustomDirectives((prev) => prev ? `${prev}\n${t.snippet}` : t.snippet)}
|
|
sx={{ justifyContent: "flex-start", textTransform: "none", fontFamily: "monospace", fontSize: "0.72rem" }}
|
|
>
|
|
{t.label}
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
</Collapse>
|
|
</Box>
|
|
<Alert severity="info" sx={{ fontSize: "0.8rem" }}>
|
|
Rule exclusions are managed on the <strong>Suppressed Rules</strong> tab.
|
|
</Alert>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
|
<Button type="submit" variant="contained">Save WAF settings</Button>
|
|
</Box>
|
|
</Stack>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|