Feat: Request explorer - #41
Merged
Merged
Conversation
Groundwork for "why was this blocked?". The engine already computed a rich verdict and then reduced it to one log line, so a customer with a request id and a complaint could only be answered by grepping the error log. Three parts. The executor now returns a per-node trace. It built node_results and threw them away, so the aggregate score was all that survived: 65 points, with nothing to say which of eight mechanisms produced them. The trace is built where the score and flags are already accumulated, so it always adds up to the aggregate rather than being a parallel calculation that can disagree. Live, a blocked request now reads keyword_filter blocked on kw:viagra, pattern_scan +5 for email:1, fingerprint +30 for suspicious-bot, total 35. Recording happens in the log phase, not in the enforcement branches. There are six of them -- block, captcha, tarpit, flag, monitor, allow -- and covering five is exactly the mistake made in shadow mode, where one of two would-block branches was missed at first. process_request stashes the verdict on ngx.ctx and one log_by_lua call records it with the response status attached. That also makes the record honest about monitoring mode: verdict "block", status 200, action "would_block". The buffering is now shared. shadow_recorder's buffer carried two concurrency defects' worth of hard-won detail -- the multi-worker drain that duplicated records, and the range claim that overshot the head and silently lost them. A second hand-rolled copy would likely have reproduced one, so it is extracted into decision_buffer and both recorders use it. The refactor is proven by the existing 76 specs passing unchanged, including the two that guard those defects. Retention is configurable and bounded: WAF_DECISION_LOG_ENABLED, _MAX, _TTL and _MIN_SCORE, declared in both nginx.conf and the Helm ConfigMap and held there by the drift gate. Off is a legitimate setting -- the log stores paths and client IPs, which some deployments would rather not retain. Not every request is kept. Anything acted on is, plus anything scoring above the threshold so a near-miss stays explicable; a clean allowed request is not, since recording those would evict the decisions somebody needs. Gates: 88 unit specs (12 new), helm drift clean at 33/33 and 15/15, config contracts hold, integration 42 passed / 2 known gaps / 0 failed.
Search the decision log, and explain any one decision down to the mechanism.
The trace had to be made honest first. My previous commit claimed it "always
adds up to the aggregate"; it did not. Three scoring paths bypassed it -- the
defense-line merge in both its blocking and non-blocking branches, and vhost
keywords added after the executor returns -- so a wp-login block reported
score 113 against a breakdown summing to 30. Each was silent, because a
plausible-looking breakdown that omits a contributor is indistinguishable
from a complete one. All three now emit trace entries, and five sampled
decisions reconcile exactly.
Rather than leave that as an invariant to be trusted, the API reports it:
unattributed_score is score minus the trace sum, and the detail view says the
explanation is partial when it is non-zero. The next scoring path added
outside the trace announces itself instead of quietly making the explanation
wrong.
The API is a filtered scan of the capped list -- vhost, endpoint, client IP,
outcome, detection flag, path substring, min score, time range -- plus
GET /decisions/{request_id} for one decision. Filtering happens in Lua rather
than via Redis indexes because the list is bounded and a second write path to
keep consistent would be the more expensive thing to get wrong.
The UI renders the breakdown as a running tally rather than a list of badges,
because the question is "where did 113 come from" and a column that adds up
answers it. Suppressed detections appear struck through, so "fired but
suppressed" stays distinguishable from "never fired". A monitoring-mode
record explains why it was allowed through despite the verdict.
Also fixed while verifying: the operator role had no decisions permission, so
every route 403'd for it. Admin and viewer had been granted it and operator
missed, which the live per-role check caught.
The contract checker now skips path-templated endpoints and prints what it
skipped. A request id is ephemeral so there is no fixed example returning
200, and a check that silently covers less than it appears to is worse than
one that admits the gap.
Gates: 88 unit specs, helm drift clean, config contracts hold, contract check
13 validated / 1 skipped, integration 42 passed / 2 known gaps / 0 failed,
typecheck 0 errors, lint 0 errors / 59 warnings.
Completes #C2's deep-link criterion, and fixes a bug it exposed. $request_id is not cached by this nginx build. Every read of ngx.var.request_id mints a fresh value, so two adjacent reads in the same request return different ids. This was found because the id on a blocked response never matched the id recorded for it -- one request, one record, two different ids -- and confirmed by logging two consecutive reads: a=744565d2... b=5dcba40f... The consequence is wider than this feature. audit_log and the webhook payload each read it independently, so the audit entry and the webhook for the same request have always carried different "request ids", and neither matched anything else. An id that correlates with nothing is worse than no id, because it invites a correlation that will not work. Read once now, cached on ngx.ctx, shared by the audit log, the webhook payload, the decision record and the response header. X-WAF-Request-Id is returned on every response and deliberately not gated behind WAF_EXPOSE_HEADERS: it is the handle support needs to look a decision up, and unlike the score and flag headers it reveals nothing about the verdict, which is what that flag exists to withhold. Verified end to end: a blocked response returned 3d733798..., and GET /decisions/3d733798... returned that decision with its breakdown. Gates: 88 unit specs, helm drift clean, config contracts hold, integration 42 passed / 2 known gaps / 0 failed.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces a “Request Explorer” capability by recording bounded enforcement decisions (including a per-mechanism trace) into Redis, exposing them via new admin API endpoints, and adding an Admin UI page to search and inspect individual decisions. It also improves request-id consistency/correlation and updates OpenAPI + contract checking to account for non-fetchable parametric routes.
Changes:
- Add a shared decision buffering primitive and a new enforcement decision recorder flushed from
redis_sync. - Add new admin API endpoints (
/decisions,/decisions/{request_id}) with RBAC permissions and OpenAPI documentation. - Add an Admin UI “Request Explorer” page and navigation wiring.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/check-api-contract.py | Skip (and report) parametric/explicitly skipped OpenAPI paths during contract checks. |
| openresty/spec/decision_recorder_spec.lua | Adds unit tests for the new decision recorder behavior (buffering, flush, trace compaction). |
| openresty/lua/webhooks.lua | Uses cached request id from ngx.ctx to avoid per-read $request_id instability. |
| openresty/lua/waf_handler.lua | Adds stable per-request id caching, trace flattening, decision stashing, log-phase recording, and response header. |
| openresty/lua/shadow_recorder.lua | Refactors shadow recorder to use shared decision_buffer. |
| openresty/lua/redis_sync.lua | Flushes enforcement decision recorder during sync (reusing existing Redis connection). |
| openresty/lua/rbac.lua | Adds decisions permissions and routing coverage for decisions endpoints. |
| openresty/lua/defense_profile_multi_executor.lua | Propagates/merges trace data in multi-profile execution results. |
| openresty/lua/defense_profile_executor.lua | Builds per-node execution trace while accumulating score/flags. |
| openresty/lua/decision_recorder.lua | New module: bounded, configurable decision logging with trace compaction. |
| openresty/lua/decision_buffer.lua | New shared buffer implementation for request-path recording + timer-based Redis flush. |
| openresty/lua/api_handlers/decisions.lua | New API handler: search decisions + fetch one decision by request id + clear log. |
| openresty/lua/admin_api.lua | Registers decisions handlers and adds parametric routing for /decisions/{request_id}. |
| openresty/conf/nginx.conf | Adds env vars + shared dict for decision buffering; records decision in log_by_lua. |
| helm/forms-waf/templates/openresty-configmap.yaml | Mirrors nginx config additions for Helm deployments. |
| docs/openapi.yaml | Documents decision schemas and /decisions endpoints (including skip metadata for param route). |
| docs/API_HANDLERS.md | Documents the new decisions handler and RBAC resource. |
| admin-ui/src/pages/analytics/RequestExplorer.tsx | New UI page to search and inspect recorded decisions + trace breakdown. |
| admin-ui/src/components/layout/Sidebar.tsx | Adds navigation link to Request Explorer. |
| admin-ui/src/App.tsx | Adds route for the Request Explorer page. |
| admin-ui/src/api/generated.ts | Extends generated OpenAPI client types with decisions endpoints/schemas. |
| admin-ui/src/api/client.ts | Adds runtime API client helpers/types for decisions endpoints. |
Suppressed comments (1)
openresty/lua/decision_recorder.lua:133
compact_trace()can return an empty Lua table when nothing is considered significant, which cjson will encode as{}and the API will decode back into an object rather than an array. Returningnilwhen there are no significant entries avoids emitting an incorrectly-typedtracefield in stored records and API responses.
local function compact_trace(trace)
if type(trace) ~= "table" then return nil end
local significant = {}
for _, entry in ipairs(trace) do
if type(entry) == "table" then
local contributed = (entry.score or 0) ~= 0 or entry.blocked
or (entry.flags and #entry.flags > 0)
or entry.suppressed
if contributed then
significant[#significant + 1] = {
node = entry.node,
defense = entry.defense,
score = entry.score or 0,
blocked = entry.blocked or nil,
flags = clip_list(entry.flags, 8, MAX_FLAG_LENGTH),
suppressed = clip_list(entry.suppressed, 8, MAX_FLAG_LENGTH),
}
if #significant >= MAX_TRACE then break end
end
end
end
return significant
end
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+60
to
+67
| local function request_id() | ||
| local rid = ngx.ctx.waf_request_id | ||
| if not rid then | ||
| rid = ngx.var.request_id or tostring(ngx.now()) | ||
| ngx.ctx.waf_request_id = rid | ||
| end | ||
| return rid | ||
| end |
Comment on lines
+433
to
+444
| local action | ||
| if status == 403 then | ||
| action = "blocked" | ||
| elseif status == 429 then | ||
| action = "tarpit" | ||
| elseif ngx.ctx.captcha_challenged then | ||
| action = "challenged" | ||
| elseif decision.profile_action == "block" then | ||
| action = "would_block" | ||
| else | ||
| action = "allowed" | ||
| end |
| -- ngx.ctx first: $request_id is not cached by this nginx build, so | ||
| -- reading it again here would produce an id matching nothing else | ||
| -- reported for this request. waf_handler sets the shared one. | ||
| request_id = ngx.ctx.waf_request_id or ngx.var.request_id or ngx.now(), |
Five findings, all valid.
Empty lists were encoded as JSON objects. cjson encodes an empty Lua table
as {} rather than [], so a decision with no flags stored "flags":{}. This is
worse than a type inconsistency: the UI writes `flags ?? []`, which does not
catch {} because it is neither null nor undefined, so .slice() on it is
undefined and the page crashes rather than showing an empty column. Same for
blocked_by, suppressed, and a trace with nothing significant in it. Empty
lists are now omitted, keeping the optional-array contract the generated
types already describe. Four specs pin it, including one asserting a
populated list still encodes as an array.
ngx.ctx.captcha_challenged was never set. I read a flag I had invented, so a
CAPTCHA challenge was recorded as allowed or would_block -- the one outcome
the log could not describe. Now set at the single serve_challenge site.
The request-id fallback produced a decimal string. tostring(ngx.now()) gives
"1786126789.12", and the route and RBAC patterns for /decisions/{id} are
hex-only, so a decision recorded under that id could never be looked up --
the one thing the id exists for. Falls back to ngx.md5, matching
$request_id's shape.
The webhook fallback emitted a number, so request_id changed type depending
on where it came from. That is a correlation key that cannot be matched on.
Hex string in every case now.
Verified live: no field in the stored records is an empty object, and the
blocked-response round trip still resolves (id 4a81cfbc..., hex, found).
Gates: 92 unit specs (4 new), helm drift clean, config contracts hold,
contract check 13 validated / 1 skipped, integration 42 passed / 2 known
gaps / 0 failed, typecheck 0 errors.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.