Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions admin-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import BehavioralAnalytics from '@/pages/analytics/BehavioralAnalytics'
import ClusterStatus from '@/pages/cluster/ClusterStatus'
import ShadowMode from '@/pages/shadow/ShadowMode'
import Suppressions from '@/pages/security/Suppressions'
import RequestExplorer from '@/pages/analytics/RequestExplorer'
import { About } from '@/pages/About'
import { Users } from '@/pages/admin/Users'
import { AuthProviders } from '@/pages/admin/AuthProviders'
Expand Down Expand Up @@ -91,6 +92,7 @@ export default function App() {
<Route path="/analytics/behavioral" element={<BehavioralAnalytics />} />
<Route path="/security/shadow" element={<ShadowMode />} />
<Route path="/security/suppressions" element={<Suppressions />} />
<Route path="/analytics/requests" element={<RequestExplorer />} />
<Route path="/cluster" element={<ClusterStatus />} />
<Route path="/about" element={<About />} />
<Route path="/admin/users" element={<Users />} />
Expand Down
83 changes: 83 additions & 0 deletions admin-ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1548,3 +1548,86 @@ export const suppressionsApi = {

clear: () => request<{ cleared: boolean }>('/suppressions', { method: 'DELETE' }),
}

// ---------------------------------------------------------------------------
// Request explorer
//
// "Why was this blocked?" as a query. trace is the per-mechanism breakdown;
// unattributed_score reports how much of the score it fails to account for.
// ---------------------------------------------------------------------------

export type DecisionAction = 'blocked' | 'allowed' | 'challenged' | 'tarpit' | 'would_block'

export interface DecisionTraceEntry {
node?: string
defense?: string
profile?: string
score?: number
blocked?: boolean
flags?: string[]
suppressed?: string[]
}

export interface Decision {
ts: number
request_id?: string
vhost_id: string
endpoint_id: string
client_ip?: string
host?: string
path?: string
method?: string
user_agent?: string
action: DecisionAction
status?: number
mode?: string
score: number
block_reason?: string
blocked_by?: string[]
flags?: string[]
trace?: DecisionTraceEntry[]
traced_score?: number
unattributed_score?: number
}

export interface DecisionRetention {
enabled?: boolean
max_records?: number
ttl_seconds?: number
min_score?: number
}

export interface DecisionSearch {
vhost_id?: string
endpoint_id?: string
client_ip?: string
action?: string
flag?: string
path?: string
min_score?: number
limit?: number
}

export const decisionsApi = {
search: (params: DecisionSearch = {}) => {
const q = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') q.set(k, String(v))
})
const qs = q.toString()
return request<{
decisions: Decision[]
count: number
limit?: number
scanned?: number
dropped_total?: number
recorded_total?: number
retention?: DecisionRetention
}>(`/decisions${qs ? `?${qs}` : ''}`)
},

get: (requestId: string) =>
request<{ decision: Decision }>(`/decisions/${encodeURIComponent(requestId)}`),

clear: () => request<{ cleared: boolean }>('/decisions', { method: 'DELETE' }),
}
172 changes: 172 additions & 0 deletions admin-ui/src/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,131 @@ export interface paths {
patch?: never;
trace?: never;
};
"/decisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Search the enforcement decision log */
get: {
parameters: {
query?: {
vhost_id?: string;
endpoint_id?: string;
client_ip?: string;
action?: "blocked" | "allowed" | "challenged" | "tarpit" | "would_block";
flag?: string;
path?: string;
min_score?: number;
since?: number;
until?: number;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Matching decisions, newest first */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
decisions: components["schemas"]["Decision"][];
count: number;
limit?: number;
scanned?: number;
/** @description Non-zero means the buffer overflowed and the log has holes. */
dropped_total?: number;
recorded_total?: number;
retention?: components["schemas"]["DecisionRetention"];
};
};
};
};
};
put?: never;
post?: never;
/** Discard the decision log */
delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cleared */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
cleared: boolean;
};
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/decisions/{request_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Explain one decision */
get: {
parameters: {
query?: never;
header?: never;
path: {
request_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The decision and its per-mechanism breakdown */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
decision: components["schemas"]["Decision"];
};
};
};
/** @description Not retained. The log is capped and time-limited, so this means the request aged out rather than that it never happened. */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/suppressions": {
parameters: {
query?: never;
Expand Down Expand Up @@ -651,6 +776,53 @@ export interface components {
high_event_rate?: number;
};
};
DecisionTraceEntry: {
node?: string;
/** @description The mechanism */
defense?: string;
profile?: string;
/** @description What this mechanism contributed */
score?: number;
blocked?: boolean;
flags?: string[];
/** @description Detections this mechanism raised that a suppression removed. Present so "fired but suppressed" is distinguishable from "never fired". */
suppressed?: string[];
};
DecisionRetention: {
enabled?: boolean;
max_records?: number;
ttl_seconds?: number;
/** @description Allowed requests below this score are not recorded. */
min_score?: number;
};
Decision: {
ts: number;
/** @description Matches the X-WAF-Request-Id response header */
request_id?: string;
vhost_id: string;
endpoint_id: string;
client_ip?: string;
host?: string;
path?: string;
method?: string;
user_agent?: string;
/**
* @description What happened, taken from the response rather than from what the pipeline intended: monitoring mode records would_block with status 200.
* @enum {string}
*/
action: "blocked" | "allowed" | "challenged" | "tarpit" | "would_block";
status?: number;
mode?: string;
score: number;
block_reason?: string;
blocked_by?: string[];
flags?: string[];
trace?: components["schemas"]["DecisionTraceEntry"][];
/** @description Sum of the trace entries. */
traced_score?: number;
/** @description score minus traced_score. Should be 0. Non-zero means some scoring path is not represented in the trace, so the breakdown is incomplete -- reported rather than assumed, because that has happened. */
unattributed_score?: number;
};
Suppression: {
/** @description Digest of scope and flag, so adding the same one twice is idempotent. */
id: string;
Expand Down
2 changes: 2 additions & 0 deletions admin-ui/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
Fingerprint,
EyeOff,
BellOff,
Search,
MessageSquare,
Workflow,
Target,
Expand Down Expand Up @@ -65,6 +66,7 @@ const navigation = [
{
name: 'Analytics',
children: [
{ name: 'Request Explorer', href: '/analytics/requests', icon: Search },
{ name: 'Behavioral', href: '/analytics/behavioral', icon: Activity },
],
},
Expand Down
Loading
Loading