Skip to content

Feat: Request explorer - #41

Merged
mclueppers merged 4 commits into
mainfrom
feat/request-explorer
Aug 8, 2026
Merged

Feat: Request explorer#41
mclueppers merged 4 commits into
mainfrom
feat/request-explorer

Conversation

@mclueppers

Copy link
Copy Markdown
Contributor

No description provided.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Returning nil when there are no significant entries avoids emitting an incorrectly-typed trace field 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
Comment thread openresty/lua/decision_recorder.lua
Comment thread openresty/lua/webhooks.lua Outdated
-- 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.
@mclueppers
mclueppers merged commit e1c3c79 into main Aug 8, 2026
6 checks passed
@mclueppers
mclueppers deleted the feat/request-explorer branch August 8, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants