+ Why a request was blocked — or why it was not — down to the mechanism.
+
+
+
+
+
+
+
+
+ {retention?.enabled === false && (
+
+
+ The decision log is turned off
+
+ Nothing new is being recorded. Set
+ WAF_DECISION_LOG_ENABLED=true
+ {' '}
+ to start.
+
+
+ )}
+
+ {incomplete && (
+
+
+ This log has holes
+
+ {data?.dropped_total} decision(s) were dropped because the recorder's buffer filled
+ up. A request you are looking for may be missing rather than never having happened.
+
+
+ )}
+
+
+
+ Filters
+
+ {retention
+ ? `Keeping up to ${retention.max_records} decisions for ${Math.round(
+ (retention.ttl_seconds ?? 0) / 86400
+ )} days. Allowed requests scoring under ${retention.min_score} are not recorded.`
+ : 'Search the recorded decisions.'}
+
+
+
+
+ No decisions match. Clean allowed requests are not recorded, so an empty result
+ can simply mean nothing was flagged.
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/docs/API_HANDLERS.md b/docs/API_HANDLERS.md
index 13c3608..fd1496b 100644
--- a/docs/API_HANDLERS.md
+++ b/docs/API_HANDLERS.md
@@ -335,6 +335,30 @@ local ok, err = utils.validate_required(data, {"field1", "field2"})
---
+### Request Explorer (`api_handlers/decisions.lua`)
+
+Answers "why was this blocked?" as a query rather than a log grep.
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | /decisions | Search: vhost, endpoint, client_ip, action, flag, path, min_score, since/until |
+| GET | /decisions/{request_id} | One decision with its per-mechanism breakdown |
+| DELETE | /decisions | Discard the log |
+
+Records are written by `decision_recorder` from `log_by_lua`, not from the
+enforcement branches — there are six of those and covering five is the failure
+mode. The log phase sees one outcome per request, and the real one, which is
+why a monitoring-mode record reads `action: would_block` with `status: 200`.
+
+`trace` is the per-mechanism breakdown, built in `defense_profile_executor`
+where score and flags are already accumulated. `unattributed_score` reports
+`score - sum(trace)` and should be 0; anything else means a scoring path is
+not represented in the trace. It is reported rather than assumed because that
+invariant broke three times during development, each time silently.
+
+Retention: `WAF_DECISION_LOG_ENABLED`, `_MAX`, `_TTL`, `_MIN_SCORE`. Disabled
+is legitimate — the log stores request paths and client IPs.
+
### Rule Suppressions (`api_handlers/suppressions.lua`)
The counterpart to shadow mode. Shadow mode names the rule that would have
@@ -399,6 +423,7 @@ Each endpoint requires specific permissions. See [RBAC Guide](RBAC.md) for full
| slack | read, update, test |
| shadow | read, promote, delete |
| suppressions | create, read, delete |
+| decisions | read, delete |
---
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index ddb3e31..10275be 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -113,6 +113,70 @@ components:
high_event_count: { type: integer }
high_event_rate: { type: integer }
+ DecisionTraceEntry:
+ type: object
+ properties:
+ node: { type: string }
+ defense: { type: string, description: The mechanism, e.g. keyword_filter }
+ profile: { type: string }
+ score: { type: integer, description: What this mechanism contributed }
+ blocked: { type: boolean }
+ flags: { type: array, items: { type: string } }
+ suppressed:
+ type: array
+ items: { type: string }
+ description: >
+ Detections this mechanism raised that a suppression removed. Present
+ so "fired but suppressed" is distinguishable from "never fired".
+
+ DecisionRetention:
+ type: object
+ properties:
+ enabled: { type: boolean }
+ max_records: { type: integer }
+ ttl_seconds: { type: integer }
+ min_score:
+ type: integer
+ description: Allowed requests below this score are not recorded.
+
+ Decision:
+ type: object
+ required: [ts, vhost_id, endpoint_id, action, score]
+ properties:
+ ts: { type: integer }
+ request_id: { type: string, description: Matches the X-WAF-Request-Id response header }
+ vhost_id: { type: string }
+ endpoint_id: { type: string }
+ client_ip: { type: string }
+ host: { type: string }
+ path: { type: string }
+ method: { type: string }
+ user_agent: { type: string }
+ action:
+ type: string
+ enum: [blocked, allowed, challenged, tarpit, would_block]
+ description: >
+ What happened, taken from the response rather than from what the
+ pipeline intended: monitoring mode records would_block with status 200.
+ status: { type: integer }
+ mode: { type: string }
+ score: { type: integer }
+ block_reason: { type: string }
+ blocked_by: { type: array, items: { type: string } }
+ flags: { type: array, items: { type: string } }
+ trace:
+ type: array
+ items: { $ref: "#/components/schemas/DecisionTraceEntry" }
+ traced_score:
+ type: integer
+ description: Sum of the trace entries.
+ unattributed_score:
+ type: integer
+ 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.
+
Suppression:
type: object
required: [id, scope_type, flag]
@@ -291,6 +355,81 @@ paths:
emitted_events: { type: array, items: { type: string } }
unavailable_events: { type: array, items: { type: string } }
+ /decisions:
+ get:
+ summary: Search the enforcement decision log
+ parameters:
+ - { name: vhost_id, in: query, schema: { type: string } }
+ - { name: endpoint_id, in: query, schema: { type: string } }
+ - { name: client_ip, in: query, schema: { type: string } }
+ - name: action
+ in: query
+ schema: { type: string, enum: [blocked, allowed, challenged, tarpit, would_block] }
+ - { name: flag, in: query, schema: { type: string } }
+ - { name: path, in: query, schema: { type: string, description: Substring match } }
+ - { name: min_score, in: query, schema: { type: integer } }
+ - { name: since, in: query, schema: { type: integer, description: Unix time } }
+ - { name: until, in: query, schema: { type: integer, description: Unix time } }
+ - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 500 } }
+ responses:
+ "200":
+ description: Matching decisions, newest first
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [decisions, count]
+ properties:
+ decisions:
+ type: array
+ items: { $ref: "#/components/schemas/Decision" }
+ count: { type: integer }
+ limit: { type: integer }
+ scanned: { type: integer }
+ dropped_total:
+ type: integer
+ description: Non-zero means the buffer overflowed and the log has holes.
+ recorded_total: { type: integer }
+ retention: { $ref: "#/components/schemas/DecisionRetention" }
+ delete:
+ summary: Discard the decision log
+ responses:
+ "200":
+ description: Cleared
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [cleared]
+ properties:
+ cleared: { type: boolean }
+
+ /decisions/{request_id}:
+ get:
+ summary: Explain one decision
+ x-contract-skip: >
+ request_id is ephemeral, so there is no fixed example that returns 200.
+ Exercised by the integration path instead.
+ parameters:
+ - name: request_id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: The decision and its per-mechanism breakdown
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [decision]
+ properties:
+ decision: { $ref: "#/components/schemas/Decision" }
+ "404":
+ description: >
+ Not retained. The log is capped and time-limited, so this means the
+ request aged out rather than that it never happened.
+
/suppressions:
get:
summary: Detections that no longer count, and where
diff --git a/helm/forms-waf/templates/openresty-configmap.yaml b/helm/forms-waf/templates/openresty-configmap.yaml
index 7ff1866..0615ea7 100644
--- a/helm/forms-waf/templates/openresty-configmap.yaml
+++ b/helm/forms-waf/templates/openresty-configmap.yaml
@@ -34,6 +34,10 @@ data:
env WAF_LOG_HMAC_KEY;
env WAF_SESSION_TTL;
env WAF_TRUSTED_PROXIES;
+ env WAF_DECISION_LOG_ENABLED;
+ env WAF_DECISION_LOG_MAX;
+ env WAF_DECISION_LOG_TTL;
+ env WAF_DECISION_LOG_MIN_SCORE;
env http_proxy;
env https_proxy;
env no_proxy;
@@ -107,6 +111,7 @@ data:
lua_shared_dict coordinator_cache 1m;
lua_shared_dict shadow_cache 10m;
lua_shared_dict suppression_cache 1m;
+ lua_shared_dict decision_cache 10m;
# Initialize modules on worker start
init_worker_by_lua_block {
diff --git a/openresty/conf/nginx.conf b/openresty/conf/nginx.conf
index 5c557f5..55c54e6 100644
--- a/openresty/conf/nginx.conf
+++ b/openresty/conf/nginx.conf
@@ -37,6 +37,10 @@ env HOSTNAME;
# Security: Trusted proxy configuration (F01)
# Comma-separated list of trusted proxy CIDRs
env WAF_TRUSTED_PROXIES;
+env WAF_DECISION_LOG_ENABLED;
+env WAF_DECISION_LOG_MAX;
+env WAF_DECISION_LOG_TTL;
+env WAF_DECISION_LOG_MIN_SCORE;
# Log integrity HMAC key (F08)
env WAF_LOG_HMAC_KEY;
# F15: SSRF protection configuration
@@ -137,6 +141,7 @@ http {
lua_shared_dict coordinator_cache 1m; # Instance coordinator state
lua_shared_dict shadow_cache 10m; # Shadow-mode would-block buffer (flushed to Redis by a timer)
lua_shared_dict suppression_cache 1m; # Rule suppressions, read on every defense node
+ lua_shared_dict decision_cache 10m; # Decision log buffer (flushed to Redis by a timer)
# Initialize modules on worker start
init_worker_by_lua_block {
@@ -308,6 +313,14 @@ http {
waf.process_request()
}
+ # Records the decision stashed during the access phase. In the log
+ # phase because that is the one place that sees exactly one outcome
+ # per request, and the outcome that actually reached the client.
+ log_by_lua_block {
+ local waf = require "waf_handler"
+ waf.log_decision()
+ }
+
# Dynamic proxy based on vhost routing config (set by waf_handler)
proxy_pass $upstream_url$request_uri;
proxy_http_version 1.1;
@@ -400,6 +413,14 @@ http {
waf.process_request()
}
+ # Records the decision stashed during the access phase. In the log
+ # phase because that is the one place that sees exactly one outcome
+ # per request, and the outcome that actually reached the client.
+ log_by_lua_block {
+ local waf = require "waf_handler"
+ waf.log_decision()
+ }
+
# Dynamic proxy based on vhost routing config (set by waf_handler)
proxy_pass $upstream_url$request_uri;
proxy_http_version 1.1;
diff --git a/openresty/lua/admin_api.lua b/openresty/lua/admin_api.lua
index 15fb377..bfe66bf 100644
--- a/openresty/lua/admin_api.lua
+++ b/openresty/lua/admin_api.lua
@@ -21,6 +21,7 @@ local webhooks_handler = require "api_handlers.webhooks"
local slack_handler = require "api_handlers.slack"
local shadow_handler = require "api_handlers.shadow"
local suppressions_handler = require "api_handlers.suppressions"
+local decisions_handler = require "api_handlers.decisions"
local geoip_handler = require "api_handlers.geoip"
local reputation_handler = require "api_handlers.reputation"
local bulk_handler = require "api_handlers.bulk"
@@ -72,6 +73,7 @@ register_handlers(webhooks_handler)
register_handlers(slack_handler)
register_handlers(shadow_handler)
register_handlers(suppressions_handler)
+register_handlers(decisions_handler)
register_handlers(geoip_handler)
register_handlers(reputation_handler)
register_handlers(bulk_handler)
@@ -203,6 +205,16 @@ function _M.handle_request()
end
end
+ -- Check for parameterized decision routes: GET /decisions/{request_id}.
+ -- Hex-only, so it cannot swallow a future /decisions/ sub-route.
+ local decision_request_id = path:match("^/decisions/([a-fA-F0-9]+)$")
+ if decision_request_id then
+ local handler = decisions_handler.resource_handlers[method]
+ if handler then
+ return handler(decision_request_id)
+ end
+ end
+
-- Check for parameterized suppression routes: DELETE /suppressions/{id}
local suppression_id = path:match("^/suppressions/([a-fA-F0-9]+)$")
if suppression_id then
diff --git a/openresty/lua/api_handlers/decisions.lua b/openresty/lua/api_handlers/decisions.lua
new file mode 100644
index 0000000..6dad0ba
--- /dev/null
+++ b/openresty/lua/api_handlers/decisions.lua
@@ -0,0 +1,214 @@
+-- api_handlers/decisions.lua
+-- The request explorer: search enforcement decisions, and explain one.
+--
+-- Reads the capped list decision_recorder writes. Filtering happens here rather
+-- than in Redis because the list is bounded (2000 by default) and a LIST has no
+-- secondary indexes -- adding them would mean a second write path to keep
+-- consistent, for a dataset small enough to scan.
+
+local _M = {}
+
+local utils = require "api_handlers.utils"
+local cjson = require "cjson.safe"
+local decision_recorder = require "decision_recorder"
+
+local KEYS = decision_recorder.get_keys()
+
+local DEFAULT_LIMIT = 50
+local MAX_LIMIT = 500
+-- Scanned per search. The list is capped at the configured retention, so this
+-- only bounds the work when retention is raised a long way.
+local MAX_SCAN = 5000
+
+--- How much of the score the trace actually accounts for.
+--
+-- This is reported rather than assumed. The invariant "the trace sums to the
+-- score" was broken in three separate places while building this -- defense
+-- lines in two merge paths, and vhost keywords added after the executor
+-- returned -- each time silently, because a plausible-looking breakdown that
+-- happens to omit a contributor looks exactly like a complete one. Surfacing
+-- the shortfall means the next scoring path added outside the trace announces
+-- itself instead of quietly making the explanation wrong.
+local function attribute(record)
+ local traced = 0
+ for _, entry in ipairs(record.trace or {}) do
+ traced = traced + (tonumber(entry.score) or 0)
+ end
+ record.traced_score = traced
+ record.unattributed_score = (record.score or 0) - traced
+ return record
+end
+
+local function as_array(t)
+ if type(t) ~= "table" then
+ return setmetatable({}, cjson.array_mt)
+ end
+ return setmetatable(t, cjson.array_mt)
+end
+
+local function query_arg(name, default)
+ local args = ngx.req.get_uri_args()
+ local value = args[name]
+ if type(value) == "table" then value = value[1] end
+ if value == nil or value == "" then return default end
+ return value
+end
+
+local function clamp(value, default, min, max)
+ local n = tonumber(value) or default
+ n = math.floor(n)
+ if n < min then return min end
+ if n > max then return max end
+ return n
+end
+
+--- Does this record match every supplied filter?
+local function matches(record, f)
+ if f.vhost_id and record.vhost_id ~= f.vhost_id then return false end
+ if f.endpoint_id and record.endpoint_id ~= f.endpoint_id then return false end
+ if f.client_ip and record.client_ip ~= f.client_ip then return false end
+ if f.action and record.action ~= f.action then return false end
+ if f.min_score and (record.score or 0) < f.min_score then return false end
+ if f.since and (record.ts or 0) < f.since then return false end
+ if f.until_ts and (record.ts or 0) > f.until_ts then return false end
+
+ if f.flag then
+ local found = false
+ for _, flag in ipairs(record.flags or {}) do
+ if flag == f.flag or flag:find(f.flag, 1, true) then
+ found = true
+ break
+ end
+ end
+ if not found then return false end
+ end
+
+ -- Plain substring, not a pattern: an operator pasting a path from a log
+ -- should not have to escape it, and a bad pattern here would error rather
+ -- than simply not match.
+ if f.path and not (record.path or ""):find(f.path, 1, true) then return false end
+
+ return true
+end
+
+--- Walk the list, newest first, collecting matches.
+local function search(red, filters, limit)
+ local raw = red:lrange(KEYS.decisions, 0, MAX_SCAN - 1)
+ if type(raw) ~= "table" then
+ return {}, 0
+ end
+
+ local out, scanned = {}, 0
+ for _, entry in ipairs(raw) do
+ scanned = scanned + 1
+ local record = cjson.decode(entry)
+ if type(record) == "table" and matches(record, filters) then
+ out[#out + 1] = attribute(record)
+ if #out >= limit then break end
+ end
+ end
+ return out, scanned
+end
+
+local function collect_filters()
+ return {
+ vhost_id = query_arg("vhost_id"),
+ endpoint_id = query_arg("endpoint_id"),
+ client_ip = query_arg("client_ip"),
+ action = query_arg("action"),
+ flag = query_arg("flag"),
+ path = query_arg("path"),
+ min_score = tonumber(query_arg("min_score")),
+ since = tonumber(query_arg("since")),
+ until_ts = tonumber(query_arg("until")),
+ }
+end
+
+_M.handlers = {}
+
+-- GET /decisions - search the decision log
+_M.handlers["GET:/decisions"] = function()
+ local limit = clamp(query_arg("limit", DEFAULT_LIMIT), DEFAULT_LIMIT, 1, MAX_LIMIT)
+
+ local red, err = utils.get_redis()
+ if not red then
+ return utils.error_response("Redis connection failed: " .. (err or "unknown"))
+ end
+
+ local decisions, scanned = search(red, collect_filters(), limit)
+ local stats = red:hgetall(KEYS.stats)
+ local total = 0
+ local dropped = 0
+ if type(stats) == "table" then
+ for i = 1, #stats, 2 do
+ if stats[i] == "total" then total = tonumber(stats[i + 1]) or 0 end
+ if stats[i] == "dropped_total" then dropped = tonumber(stats[i + 1]) or 0 end
+ end
+ end
+ utils.close_redis(red)
+
+ local cfg = decision_recorder.get_config()
+
+ return utils.json_response({
+ decisions = as_array(decisions),
+ count = #decisions,
+ limit = limit,
+ scanned = scanned,
+ -- Non-zero means the recorder's buffer overflowed and the log has holes.
+ dropped_total = dropped,
+ recorded_total = total,
+ retention = {
+ enabled = cfg.enabled,
+ max_records = cfg.max_records,
+ ttl_seconds = cfg.ttl,
+ min_score = cfg.min_score,
+ },
+ })
+end
+
+-- DELETE /decisions - discard the log
+_M.handlers["DELETE:/decisions"] = function()
+ local red, err = utils.get_redis()
+ if not red then
+ return utils.error_response("Redis connection failed: " .. (err or "unknown"))
+ end
+
+ red:del(KEYS.decisions)
+ red:del(KEYS.stats)
+ utils.close_redis(red)
+
+ ngx.log(ngx.WARN, "DECISION_LOG_CLEARED by=",
+ (ngx.ctx.admin_user and ngx.ctx.admin_user.username) or "unknown")
+
+ return utils.json_response({ cleared = true })
+end
+
+-- Parametric: GET /decisions/{request_id} - explain one decision
+_M.resource_handlers = {}
+
+_M.resource_handlers["GET"] = function(request_id)
+ local red, err = utils.get_redis()
+ if not red then
+ return utils.error_response("Redis connection failed: " .. (err or "unknown"))
+ end
+
+ local raw = red:lrange(KEYS.decisions, 0, MAX_SCAN - 1)
+ utils.close_redis(red)
+
+ if type(raw) == "table" then
+ for _, entry in ipairs(raw) do
+ local record = cjson.decode(entry)
+ if type(record) == "table" and record.request_id == request_id then
+ return utils.json_response({ decision = attribute(record) })
+ end
+ end
+ end
+
+ -- 404 here means "not retained", which is a different thing from "never
+ -- happened" -- the log is capped and time-limited by design.
+ return utils.error_response(
+ "No decision recorded for request id '" .. tostring(request_id) ..
+ "'. The log is capped and time-limited, so an older request may have aged out.", 404)
+end
+
+return _M
diff --git a/openresty/lua/decision_buffer.lua b/openresty/lua/decision_buffer.lua
new file mode 100644
index 0000000..ff10307
--- /dev/null
+++ b/openresty/lua/decision_buffer.lua
@@ -0,0 +1,186 @@
+--[[
+ Decision buffer
+ ===============
+ Shared machinery for "record something in the request path, write it to Redis
+ from a timer". Extracted from shadow_recorder when a second recorder needed
+ the same thing, because what it holds is not boilerplate -- it is two
+ concurrency defects that were each found the hard way:
+
+ * redis_sync's timer runs on EVERY worker (24 on a typical box). Reading
+ the tail, draining, then writing it back let every worker drain the same
+ slots, turning one decision into one record per worker.
+
+ * Claiming a range with incr(TAIL, pending) fixed the duplicates and
+ introduced something worse. A second worker claiming the same `pending`
+ pushed the tail past the head, into slots the writer had not filled yet.
+ Those claims drained nothing, and every record written into the gap was
+ skipped for good -- no duplicates, so the obvious test passed, while
+ records were silently lost.
+
+ A second hand-rolled copy of this would very likely reproduce one of them.
+
+ The design that survives both: a single drainer holds an atomic add() lock,
+ and the tail advances one slot at a time as each is written, so a drain that
+ dies part-way resumes exactly where it stopped -- neither replaying a record
+ nor losing one.
+
+ Callers supply the Redis writes, which is the only part that differs between
+ recorders.
+]]
+
+local cjson = require "cjson.safe"
+
+local _M = {}
+
+local Buffer = {}
+Buffer.__index = Buffer
+
+--- @param opts table
+-- dict_name name of the lua_shared_dict to buffer in (required)
+-- prefix key namespace within that dict (required)
+-- max_buffered records held between flushes, beyond which new ones are dropped
+-- slot_ttl seconds a buffered record survives, so a stalled flush cannot
+-- pin memory indefinitely
+-- lock_ttl seconds the drain lock survives a worker that dies outright
+function _M.new(opts)
+ assert(type(opts) == "table" and opts.dict_name and opts.prefix,
+ "decision_buffer.new requires dict_name and prefix")
+
+ return setmetatable({
+ dict_name = opts.dict_name,
+ prefix = opts.prefix,
+ max_buffered = opts.max_buffered or 500,
+ slot_ttl = opts.slot_ttl or 600,
+ lock_ttl = opts.lock_ttl or 30,
+ head_key = opts.prefix .. ":head",
+ tail_key = opts.prefix .. ":tail",
+ dropped_key = opts.prefix .. ":dropped",
+ lock_key = opts.prefix .. ":draining",
+ slot_prefix = opts.prefix .. ":rec:",
+ }, Buffer)
+end
+
+-- Resolved per call rather than at construction: the dict does not exist when a
+-- module is required at init, only once nginx has set up shared memory.
+function Buffer:dict()
+ return ngx.shared[self.dict_name]
+end
+
+--- Buffer one record. Called from the request path, so it must stay
+--- allocation-light and must never touch Redis.
+-- @return true, or false plus a reason
+function Buffer:record(entry)
+ local dict = self:dict()
+ if not dict or type(entry) ~= "table" then
+ return false
+ end
+
+ local head = dict:incr(self.head_key, 1, 0)
+ if not head then
+ return false
+ end
+
+ local tail = dict:get(self.tail_key) or 0
+ if head - tail > self.max_buffered then
+ -- Drop the new record rather than evicting an older one, and count the
+ -- loss so a consumer can say "showing a sample" instead of implying
+ -- completeness.
+ dict:incr(self.dropped_key, 1, 0)
+ return false, "buffer full"
+ end
+
+ local encoded = cjson.encode(entry)
+ if not encoded then
+ return false
+ end
+
+ dict:set(self.slot_prefix .. head, encoded, self.slot_ttl)
+ return true
+end
+
+--- Write one slot range. Called under the drain lock, and separated out so
+--- flush() can run it under pcall and still release that lock.
+local function drain_range(self, dict, red, from, to, write_record)
+ local flushed = 0
+ for slot = from, to do
+ local key = self.slot_prefix .. slot
+ local raw = dict:get(key)
+ if raw then
+ local decoded = cjson.decode(raw)
+ if type(decoded) == "table" then
+ write_record(red, decoded, raw)
+ flushed = flushed + 1
+ end
+ dict:delete(key)
+ end
+ -- One slot at a time, not a jump at the end: this is what makes a
+ -- failed drain resumable rather than lossy.
+ dict:set(self.tail_key, slot)
+ end
+ return flushed
+end
+
+--- Drain into Redis. Timer context only.
+-- @param red an open Redis connection
+-- @param write_record function(red, decoded, raw) called per record
+-- @return flushed count, dropped-since-last-flush count
+function Buffer:flush(red, write_record)
+ local dict = self:dict()
+ if not dict or not red or type(write_record) ~= "function" then
+ return 0, 0
+ end
+
+ local head = dict:get(self.head_key) or 0
+ local tail = dict:get(self.tail_key) or 0
+ if head <= tail then
+ return 0, 0
+ end
+
+ -- add() is atomic and fails when the key already exists, so exactly one
+ -- worker drains and the rest return immediately.
+ if not dict:add(self.lock_key, 1, self.lock_ttl) then
+ return 0, 0
+ end
+
+ local ok, flushed = pcall(drain_range, self, dict, red, tail + 1, head, write_record)
+ if not ok then
+ -- Release rather than leaving the buffer wedged until the lock TTL. The
+ -- tail already advanced per completed slot, so the next drain resumes
+ -- where this one stopped.
+ dict:delete(self.lock_key)
+ ngx.log(ngx.ERR, "decision_buffer(", self.prefix,
+ ") flush failed, resuming from last completed slot: ", tostring(flushed))
+ return 0, 0
+ end
+
+ local dropped = dict:get(self.dropped_key) or 0
+ if dropped > 0 then
+ dict:set(self.dropped_key, 0)
+ end
+
+ dict:delete(self.lock_key)
+ return flushed, dropped
+end
+
+--- Records buffered but not yet written.
+function Buffer:depth()
+ local dict = self:dict()
+ if not dict then return 0 end
+ local head = dict:get(self.head_key) or 0
+ local tail = dict:get(self.tail_key) or 0
+ local depth = head - tail
+ return depth > 0 and depth or 0
+end
+
+--- Trim a value that originates from the request, so a long or hostile field
+--- cannot bloat storage. Shared because every recorder needs it.
+function _M.clip(value, limit)
+ if value == nil then return nil end
+ value = tostring(value)
+ if #value > limit then
+ return value:sub(1, limit) .. "..."
+ end
+ return value
+end
+
+return _M
diff --git a/openresty/lua/decision_recorder.lua b/openresty/lua/decision_recorder.lua
new file mode 100644
index 0000000..b8e1001
--- /dev/null
+++ b/openresty/lua/decision_recorder.lua
@@ -0,0 +1,225 @@
+--[[
+ Decision recorder
+ =================
+ Answers "why was this blocked?" without a log grep.
+
+ The engine already computes a rich verdict -- a score, a flag per detection,
+ and now a per-node trace of which mechanism contributed what. All of it was
+ being reduced to one log line and discarded. Support questions about a
+ specific blocked request had no better answer than "look for it in the error
+ log", which does not survive contact with a customer who has a request id and
+ a complaint.
+
+ Recorded in the log phase, deliberately
+ ---------------------------------------
+ process_request has several enforcement branches -- block by profile, block by
+ threshold, CAPTCHA challenge, tarpit, monitoring pass-through, plain allow --
+ and hooking each one is how you end up with a recorder that covers most of
+ them. That exact mistake was made in shadow mode, where one of two would-block
+ branches was missed at first.
+
+ So process_request stashes the verdict on ngx.ctx and a single log-phase call
+ records it with the response status attached. One record per request, with
+ the outcome that actually happened rather than the one predicted mid-pipeline.
+
+ What is kept
+ ------------
+ Not every request: a busy site would fill Redis with successful form posts
+ that nobody will ever look up. By default only decisions that did something --
+ blocked, challenged, tarpitted, or would-have-blocked -- plus anything scoring
+ above a threshold, so a near-miss is still explicable. Both bounded and both
+ configurable, because "why did this get through?" is as common a question as
+ "why was this blocked?".
+]]
+
+local buffer_lib = require "decision_buffer"
+
+local _M = {}
+
+local KEYS = {
+ decisions = "waf:decisions", -- capped list, newest first
+ stats = "waf:decisions:stats", -- HASH totals
+}
+
+-- Bounds. Ceilings, not tuning knobs: each is what stops the decision log
+-- becoming its own incident on a busy site.
+local MAX_BUFFERED = 500
+local MAX_TRACE = 20 -- nodes itemised per record
+local MAX_FLAGS = 30
+local MAX_FLAG_LENGTH = 120
+local MAX_PATH_LENGTH = 256
+local MAX_UA_LENGTH = 200
+
+local DEFAULT_MAX_RECORDS = 2000
+local DEFAULT_TTL = 7 * 24 * 3600
+local DEFAULT_MIN_SCORE = 1
+
+local clip = buffer_lib.clip
+
+local buffer = buffer_lib.new({
+ dict_name = "decision_cache",
+ prefix = "decision",
+ max_buffered = MAX_BUFFERED,
+})
+
+--- Retention is configurable because the right answer differs by deployment: a
+--- high-traffic site wants a short window, an audited one wants a long tail.
+--- Read once per worker -- these do not change without a restart.
+local _config
+local function config()
+ if _config then return _config end
+
+ local function num(name, default, min, max)
+ local v = tonumber(os.getenv(name))
+ if not v then return default end
+ if v < min then return min end
+ if max and v > max then return max end
+ return v
+ end
+
+ _config = {
+ -- Off is a legitimate choice: the decision log stores request paths and
+ -- client IPs, which some deployments would rather not retain at all.
+ enabled = os.getenv("WAF_DECISION_LOG_ENABLED") ~= "false",
+ max_records = num("WAF_DECISION_LOG_MAX", DEFAULT_MAX_RECORDS, 10, 50000),
+ ttl = num("WAF_DECISION_LOG_TTL", DEFAULT_TTL, 60),
+ -- Below this score an allowed request is not worth a record. 0 records
+ -- everything, which is supported but will fill the buffer on a busy site.
+ min_score = num("WAF_DECISION_LOG_MIN_SCORE", DEFAULT_MIN_SCORE, 0),
+ }
+ return _config
+end
+
+--- Reset the memoised config. Tests only.
+function _M._reset_config()
+ _config = nil
+end
+
+--- Clip a list, or return nil if there is nothing in it.
+--
+-- nil rather than {} on purpose: cjson encodes an empty Lua table as {}, not [],
+-- so an empty flags list would arrive at the UI as an object. `flags ?? []` does
+-- not catch that -- {} is neither null nor undefined -- and .slice() on it is
+-- undefined, which is a crashed page rather than an empty column. Omitting the
+-- field keeps the optional-array contract the generated types already describe.
+local function clip_list(list, limit, max_len)
+ if type(list) ~= "table" or #list == 0 then return nil end
+ local out = {}
+ for i = 1, math.min(#list, limit) do
+ out[i] = clip(list[i], max_len)
+ end
+ return out
+end
+
+--- Trim a trace to what is useful in a detail view: the mechanisms that did
+--- something. A profile with 30 nodes mostly reports zeros, and storing them
+--- crowds out the entries that explain the verdict.
+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
+ -- Same reasoning as clip_list: an empty trace must be absent, not {}.
+ if #significant == 0 then return nil end
+ return significant
+end
+
+--- Should this decision be kept?
+function _M.should_record(action, score)
+ local cfg = config()
+ if not cfg.enabled then return false end
+ if action and action ~= "allowed" then return true end
+ return (score or 0) >= cfg.min_score
+end
+
+--- Record one enforcement decision. Log phase only -- must not touch Redis.
+-- @param decision table request_id, vhost_id, endpoint_id, client_ip, host,
+-- path, method, user_agent, action, status, mode, score,
+-- flags, blocked_by, block_reason, trace
+function _M.record(decision)
+ if type(decision) ~= "table" then
+ return false
+ end
+ if not _M.should_record(decision.action, decision.score) then
+ return false
+ end
+
+ return buffer:record({
+ ts = ngx.time(),
+ request_id = decision.request_id,
+ vhost_id = decision.vhost_id or "unknown",
+ endpoint_id = decision.endpoint_id or "global",
+ client_ip = decision.client_ip,
+ host = clip(decision.host, MAX_PATH_LENGTH),
+ path = clip(decision.path, MAX_PATH_LENGTH),
+ method = decision.method,
+ user_agent = clip(decision.user_agent, MAX_UA_LENGTH),
+ action = decision.action,
+ status = decision.status,
+ mode = decision.mode,
+ score = decision.score or 0,
+ block_reason = clip(decision.block_reason, MAX_FLAG_LENGTH),
+ blocked_by = clip_list(decision.blocked_by, 10, MAX_FLAG_LENGTH),
+ flags = clip_list(decision.flags, MAX_FLAGS, MAX_FLAG_LENGTH),
+ trace = compact_trace(decision.trace),
+ })
+end
+
+local function write_record(red, decoded, raw)
+ red:lpush(KEYS.decisions, raw)
+ red:hincrby(KEYS.stats, "total", 1)
+ red:hincrby(KEYS.stats, "action:" .. (decoded.action or "unknown"), 1)
+end
+
+--- Drain into Redis. Timer context only.
+function _M.flush(red)
+ if not red then
+ return 0
+ end
+
+ local cfg = config()
+ local flushed, dropped = buffer:flush(red, write_record)
+
+ if flushed > 0 then
+ red:ltrim(KEYS.decisions, 0, cfg.max_records - 1)
+ red:expire(KEYS.decisions, cfg.ttl)
+ red:expire(KEYS.stats, cfg.ttl)
+ end
+
+ if dropped > 0 then
+ red:hincrby(KEYS.stats, "dropped_total", dropped)
+ end
+
+ return flushed
+end
+
+function _M.get_keys()
+ return KEYS
+end
+
+function _M.get_config()
+ return config()
+end
+
+function _M.buffer_depth()
+ return buffer:depth()
+end
+
+return _M
diff --git a/openresty/lua/defense_profile_executor.lua b/openresty/lua/defense_profile_executor.lua
index 8de65be..4e67156 100644
--- a/openresty/lua/defense_profile_executor.lua
+++ b/openresty/lua/defense_profile_executor.lua
@@ -583,9 +583,20 @@ function _M.execute(profile, request_context)
details = {},
final_action = nil,
action_config = nil,
- would_block_reasons = {} -- Track what would have blocked in monitoring mode
+ would_block_reasons = {}, -- Track what would have blocked in monitoring mode
+ -- Per-node record of what each mechanism contributed. The aggregate score
+ -- alone cannot answer "why was this blocked?" -- 65 points tells an
+ -- operator nothing about which of eight mechanisms produced them. Built
+ -- at the same place the score and flags are accumulated, so the trace
+ -- always adds up to the aggregate rather than being a parallel guess.
+ trace = {}
}
+ -- A pathological or looping graph must not turn the trace into the memory
+ -- problem. Nodes executed past this still count toward the score; they just
+ -- stop being individually itemised.
+ local MAX_TRACE_ENTRIES = 50
+
-- Node results cache
local node_results = {}
@@ -633,6 +644,20 @@ function _M.execute(profile, request_context)
table.insert(exec_context.flags, flag)
end
end
+
+ if #exec_context.trace < MAX_TRACE_ENTRIES then
+ table.insert(exec_context.trace, {
+ node = node.id,
+ defense = node.defense,
+ score = result.score or 0,
+ flags = result.flags,
+ blocked = result.blocked or false,
+ -- Carried through so the explorer can show "this fired but
+ -- was suppressed", which is otherwise indistinguishable
+ -- from never having fired at all.
+ suppressed = result.details and result.details.suppressed or nil,
+ })
+ end
end
if result.details then
for k, v in pairs(result.details) do
@@ -754,7 +779,8 @@ function _M.execute(profile, request_context)
execution_time_ms = elapsed_ms,
nodes_executed = iterations,
would_block_reasons = exec_context.would_block_reasons, -- What would have blocked (useful in monitoring mode)
- is_monitoring_mode = is_monitoring_mode
+ is_monitoring_mode = is_monitoring_mode,
+ trace = exec_context.trace
}
end
diff --git a/openresty/lua/defense_profile_multi_executor.lua b/openresty/lua/defense_profile_multi_executor.lua
index a23b66c..75221ac 100644
--- a/openresty/lua/defense_profile_multi_executor.lua
+++ b/openresty/lua/defense_profile_multi_executor.lua
@@ -353,7 +353,10 @@ function _M.aggregate(results, config, start_time)
action = r.result.action,
score = r.result.score,
flags = r.result.flags,
- execution_time_ms = r.result.execution_time_ms
+ execution_time_ms = r.result.execution_time_ms,
+ -- Per-node breakdown, so a decision can be explained down to the
+ -- mechanism rather than stopping at "profile X scored 65".
+ trace = r.result.trace
}
end
@@ -443,6 +446,33 @@ function _M.execute(config, request_context)
lines_result.blocked_by_line or "none"
))
+ -- The base result of a multi-profile run carries no top-level trace: the
+ -- per-node entries sit under profile_results[id].trace. Setting a trace on
+ -- the merged result makes the consumer read that and stop looking, so the
+ -- base entries have to be folded in here or they are lost -- a wp-login
+ -- decision reported score 113 against a trace summing to 83, the difference
+ -- being the fingerprint node the base profile had already scored.
+ local function base_trace(result)
+ local flat = {}
+ if type(result.trace) == "table" then
+ for _, entry in ipairs(result.trace) do
+ flat[#flat + 1] = entry
+ end
+ return flat
+ end
+ for profile_id, r in pairs(result.profile_results or {}) do
+ if type(r) == "table" and type(r.trace) == "table" then
+ for _, entry in ipairs(r.trace) do
+ if type(entry) == "table" then
+ entry.profile = profile_id
+ flat[#flat + 1] = entry
+ end
+ end
+ end
+ end
+ return flat
+ end
+
-- Merge results: if defense lines block, the final action is block
if lines_result.action == "block" then
-- Combine flags from base and defense lines
@@ -454,11 +484,20 @@ function _M.execute(config, request_context)
table.insert(all_flags, flag)
end
+ local merged_trace = base_trace(base_result)
+ table.insert(merged_trace, {
+ defense = "defense_line:" .. (lines_result.blocked_by_line or "unknown"),
+ score = lines_result.score or 0,
+ blocked = true,
+ flags = lines_result.flags,
+ })
+
return {
action = "block",
action_config = lines_result.action_config or base_result.action_config,
score = base_result.score + lines_result.score,
flags = all_flags,
+ trace = merged_trace,
details = {
base_profile = base_result.details,
defense_lines = lines_result.details
@@ -484,11 +523,25 @@ function _M.execute(config, request_context)
table.insert(all_flags, flag)
end
+ -- Defense lines contribute score even when they do not block, so this merge
+ -- needs the same trace entry as the blocking branch above. Tracing only the
+ -- blocking one left a wp-login decision reporting score 113 against a trace
+ -- summing to 30.
+ local merged_trace = base_trace(base_result)
+ if (lines_result.score or 0) ~= 0 or (lines_result.flags and #lines_result.flags > 0) then
+ table.insert(merged_trace, {
+ defense = "defense_line:" .. (lines_result.blocked_by_line or "evaluated"),
+ score = lines_result.score or 0,
+ flags = lines_result.flags,
+ })
+ end
+
return {
action = "allow",
action_config = base_result.action_config,
score = base_result.score + lines_result.score,
flags = all_flags,
+ trace = merged_trace,
details = {
base_profile = base_result.details,
defense_lines = lines_result.details
diff --git a/openresty/lua/rbac.lua b/openresty/lua/rbac.lua
index 537147b..e4bfa46 100644
--- a/openresty/lua/rbac.lua
+++ b/openresty/lua/rbac.lua
@@ -39,6 +39,7 @@ local DEFAULT_ROLES = {
slack = {"read", "update", "test", "reset"},
shadow = {"read", "promote", "delete"},
suppressions = {"create", "read", "delete"},
+ decisions = {"read", "delete"},
geoip = {"read", "update", "reload"},
reputation = {"read", "update"},
timing = {"read", "update"},
@@ -72,6 +73,7 @@ local DEFAULT_ROLES = {
slack = {"read"},
shadow = {"read"},
suppressions = {"create", "read", "delete"},
+ decisions = {"read", "delete"},
geoip = {"read"},
reputation = {"read"},
timing = {"read"},
@@ -102,6 +104,7 @@ local DEFAULT_ROLES = {
slack = {"read"},
shadow = {"read"},
suppressions = {"read"},
+ decisions = {"read"},
geoip = {"read"},
reputation = {"read"},
timing = {"read"},
@@ -198,6 +201,8 @@ local ENDPOINT_PERMISSIONS = {
["GET:/suppressions"] = {resource = "suppressions", action = "read"},
["POST:/suppressions"] = {resource = "suppressions", action = "create"},
["DELETE:/suppressions"] = {resource = "suppressions", action = "delete"},
+ ["GET:/decisions"] = {resource = "decisions", action = "read"},
+ ["DELETE:/decisions"] = {resource = "decisions", action = "delete"},
-- Slack notifications
["GET:/slack/config"] = {resource = "slack", action = "read"},
@@ -309,6 +314,11 @@ local PARAMETRIC_PERMISSIONS = {
suppressions = {
["DELETE"] = {resource = "suppressions", action = "delete"},
},
+ -- Decisions. Not vhost-scoped: the id is a request id, not a vhost. A
+ -- scoped role still sees every vhost's decisions, which is worth knowing.
+ decisions = {
+ ["GET"] = {resource = "decisions", action = "read"},
+ },
-- Endpoints
endpoints = {
["GET"] = {resource = "endpoints", action = "read", scoped = true},
@@ -542,6 +552,11 @@ function _M.get_endpoint_permission(method, path)
return PARAMETRIC_PERMISSIONS.endpoints[handler_key], endpoint_id, "endpoint"
end
+ local decision_request_id = path:match("^/decisions/([a-fA-F0-9]+)$")
+ if decision_request_id then
+ return PARAMETRIC_PERMISSIONS.decisions[method], decision_request_id, "decision"
+ end
+
local suppression_id = path:match("^/suppressions/([a-fA-F0-9]+)$")
if suppression_id then
return PARAMETRIC_PERMISSIONS.suppressions[method], suppression_id, "suppression"
diff --git a/openresty/lua/redis_sync.lua b/openresty/lua/redis_sync.lua
index dd2befd..7f15775 100644
--- a/openresty/lua/redis_sync.lua
+++ b/openresty/lua/redis_sync.lua
@@ -1205,6 +1205,17 @@ local function do_sync()
-- Drain the shadow-mode buffer here rather than on its own timer: this
-- function already holds an open connection and runs on a predictable
-- interval.
+ -- Same treatment for the enforcement decision log.
+ local ok_dec, decision_recorder = pcall(require, "decision_recorder")
+ if ok_dec and decision_recorder then
+ local ok_flush, flushed = pcall(decision_recorder.flush, red)
+ if not ok_flush then
+ ngx.log(ngx.ERR, "decision recorder flush failed: ", tostring(flushed))
+ elseif flushed > 0 then
+ ngx.log(ngx.INFO, "decision recorder: flushed ", flushed, " decisions")
+ end
+ end
+
local ok_shadow, shadow_recorder = pcall(require, "shadow_recorder")
if ok_shadow and shadow_recorder then
local ok_flush, flushed = pcall(shadow_recorder.flush, red)
diff --git a/openresty/lua/shadow_recorder.lua b/openresty/lua/shadow_recorder.lua
index 02e6342..e130f51 100644
--- a/openresty/lua/shadow_recorder.lua
+++ b/openresty/lua/shadow_recorder.lua
@@ -16,16 +16,18 @@
starve redis_sync (R-03). A single periodic flush is used instead, the
same shape field_learner already uses for its batching.
+ The buffering itself lives in decision_buffer, shared with the enforcement
+ recorder -- it carries two concurrency defects' worth of hard-won detail, and
+ a second copy would likely reproduce one of them.
+
Storage is bounded on purpose. A WAF in monitoring mode on a busy site sees
everything, and an unbounded recorder becomes its own outage.
]]
-local cjson = require "cjson.safe"
+local buffer_lib = require "decision_buffer"
local _M = {}
-local shadow_cache = ngx.shared.shadow_cache
-
-- Redis keys
local KEYS = {
decisions = "waf:shadow:decisions", -- capped list of recent records
@@ -44,49 +46,26 @@ local DECISION_TTL = 7 * 24 * 3600
local MAX_FLAG_LENGTH = 120
local MAX_PATH_LENGTH = 256
-local BUFFER_HEAD = "shadow:head" -- next write slot
-local BUFFER_TAIL = "shadow:tail" -- last slot successfully flushed
-local DROPPED = "shadow:dropped"
-local DRAIN_LOCK = "shadow:draining"
--- Long enough that a real drain never trips it, short enough that a worker
--- killed mid-drain cannot wedge the buffer for more than one sync interval or two.
-local DRAIN_LOCK_TTL = 30
-
---- Trim a value that originates from the request, so a long or hostile field
---- cannot bloat storage.
-local function clip(value, limit)
- if value == nil then return nil end
- value = tostring(value)
- if #value > limit then
- return value:sub(1, limit) .. "..."
- end
- return value
-end
+local clip = buffer_lib.clip
+
+-- Keys stay "shadow:*" so an in-flight buffer survives the upgrade to the
+-- shared implementation rather than being stranded and expiring unflushed.
+local buffer = buffer_lib.new({
+ dict_name = "shadow_cache",
+ prefix = "shadow",
+ max_buffered = MAX_BUFFERED,
+})
--- Record one would-block decision. Called from the request path, so this must
--- stay allocation-light and must never touch Redis.
-- @param decision table vhost_id, endpoint_id, client_ip, host, path, method,
-- score, blocked_by (array), flags (array)
function _M.record(decision)
- if not shadow_cache or type(decision) ~= "table" then
+ if type(decision) ~= "table" then
return false
end
- local head = shadow_cache:incr(BUFFER_HEAD, 1, 0)
- if not head then
- return false
- end
-
- local tail = shadow_cache:get(BUFFER_TAIL) or 0
- if head - tail > MAX_BUFFERED then
- -- Buffer is full: drop this record rather than evicting an older one, and
- -- count the loss so the UI can say "showing a sample" instead of implying
- -- completeness.
- shadow_cache:incr(DROPPED, 1, 0)
- return false, "buffer full"
- end
-
- local record = cjson.encode({
+ return buffer:record({
ts = ngx.time(),
vhost_id = decision.vhost_id or "unknown",
endpoint_id = decision.endpoint_id or "global",
@@ -98,100 +77,37 @@ function _M.record(decision)
blocked_by = decision.blocked_by,
flags = decision.flags,
})
- if not record then
- return false
- end
-
- -- Slots expire well after a flush would have consumed them, so a stalled
- -- flush cannot pin memory indefinitely.
- shadow_cache:set("shadow:rec:" .. head, record, 600)
- return true
end
---- Write one slot range to Redis. Called under the drain lock.
--- Separated out so flush() can run it under pcall and still release the lock:
--- otherwise a failure part-way wedges the buffer until the lock TTL expires.
-local function drain_range(red, from, to)
- local flushed = 0
- for slot = from, to do
- local key = "shadow:rec:" .. slot
- local record = shadow_cache:get(key)
- if record then
- local decoded = cjson.decode(record)
- if decoded then
- red:lpush(KEYS.decisions, record)
-
- local scope = (decoded.vhost_id or "unknown") .. "|" .. (decoded.endpoint_id or "global")
- red:hincrby(KEYS.endpoints, scope, 1)
- red:hincrby(KEYS.stats, "would_block_total", 1)
-
- for _, rule in ipairs(decoded.blocked_by or {}) do
- red:hincrby(KEYS.rules, clip(rule, MAX_FLAG_LENGTH), 1)
- end
-
- -- blocked_by names the profile ("legacy"), which tells an
- -- operator nothing about what to suppress. The flags carry the
- -- actual reason -- kw:viagra, fp_flag:suspicious-bot -- so count
- -- those too; they are what the diff view is for.
- for _, flag in ipairs(decoded.flags or {}) do
- red:hincrby(KEYS.flags, clip(flag, MAX_FLAG_LENGTH), 1)
- end
- flushed = flushed + 1
- end
- shadow_cache:delete(key)
- end
- -- Advance one slot at a time rather than jumping the tail at the end: a
- -- drain that dies part-way resumes from the last completed slot, with
- -- neither a replayed record nor a lost one.
- shadow_cache:set(BUFFER_TAIL, slot)
+local function write_record(red, decoded, raw)
+ red:lpush(KEYS.decisions, raw)
+
+ local scope = (decoded.vhost_id or "unknown") .. "|" .. (decoded.endpoint_id or "global")
+ red:hincrby(KEYS.endpoints, scope, 1)
+ red:hincrby(KEYS.stats, "would_block_total", 1)
+
+ for _, rule in ipairs(decoded.blocked_by or {}) do
+ red:hincrby(KEYS.rules, clip(rule, MAX_FLAG_LENGTH), 1)
+ end
+
+ -- blocked_by names the profile ("legacy"), which tells an operator nothing
+ -- about what to suppress. The flags carry the actual reason -- kw:viagra,
+ -- fp_flag:suspicious-bot -- so count those too; they are what the diff view
+ -- is for.
+ for _, flag in ipairs(decoded.flags or {}) do
+ red:hincrby(KEYS.flags, clip(flag, MAX_FLAG_LENGTH), 1)
end
- return flushed
end
--- Drain the buffer into Redis. Timer context only.
-- @param red an open Redis connection
-- @return number of records flushed
function _M.flush(red)
- if not shadow_cache or not red then
+ if not red then
return 0
end
- local head = shadow_cache:get(BUFFER_HEAD) or 0
- local tail = shadow_cache:get(BUFFER_TAIL) or 0
- if head <= tail then
- return 0
- end
-
- -- redis_sync's timer runs on EVERY worker, so several of them reach this at
- -- the same moment. Reading the tail, draining, then writing the tail back
- -- lets each worker drain the same slots -- three workers turned one
- -- would-block decision into three recorded copies, which overstated the
- -- impact of promoting an endpoint by 3x.
- --
- -- Claiming a range with incr(TAIL, pending) fixed the duplicates and
- -- introduced something worse: a second worker claiming the same `pending`
- -- pushed the tail past the head, into slots the writer had not filled yet.
- -- Those claims drained nothing, and every record written into that gap was
- -- then skipped for good, because the head <= tail guard above reports an
- -- empty buffer. Silent loss in the thing whose whole job is to be a
- -- trustworthy sample.
- --
- -- add() is atomic and fails when the key already exists, so exactly one
- -- worker drains and the rest return immediately.
- if not shadow_cache:add(DRAIN_LOCK, 1, DRAIN_LOCK_TTL) then
- return 0
- end
-
- -- The TTL on the lock only covers a worker that dies outright. A drain that
- -- merely fails -- Redis going away mid-range -- releases here instead, so the
- -- next sync interval retries rather than waiting the TTL out.
- local ok, flushed = pcall(drain_range, red, tail + 1, head)
- if not ok then
- shadow_cache:delete(DRAIN_LOCK)
- ngx.log(ngx.ERR, "shadow flush failed, resuming from last completed slot: ",
- tostring(flushed))
- return 0
- end
+ local flushed, dropped = buffer:flush(red, write_record)
if flushed > 0 then
red:ltrim(KEYS.decisions, 0, MAX_DECISIONS - 1)
@@ -202,13 +118,10 @@ function _M.flush(red)
red:expire(KEYS.stats, DECISION_TTL)
end
- local dropped = shadow_cache:get(DROPPED)
- if dropped and dropped > 0 then
+ if dropped > 0 then
red:hincrby(KEYS.stats, "dropped_total", dropped)
- shadow_cache:set(DROPPED, 0)
end
- shadow_cache:delete(DRAIN_LOCK)
return flushed
end
@@ -218,10 +131,7 @@ end
--- Exposed for tests and for the API's "is anything buffered" check.
function _M.buffer_depth()
- if not shadow_cache then return 0 end
- local head = shadow_cache:get(BUFFER_HEAD) or 0
- local tail = shadow_cache:get(BUFFER_TAIL) or 0
- return math.max(0, head - tail)
+ return buffer:depth()
end
return _M
diff --git a/openresty/lua/waf_handler.lua b/openresty/lua/waf_handler.lua
index 1bdaa31..6bca387 100644
--- a/openresty/lua/waf_handler.lua
+++ b/openresty/lua/waf_handler.lua
@@ -47,6 +47,51 @@ end
-- F08: HMAC key for log integrity (optional)
local LOG_HMAC_KEY = os.getenv("WAF_LOG_HMAC_KEY")
+local collect_trace
+
+--- The request id, stable for the life of the request.
+--
+-- $request_id is NOT cached by this nginx build: every read of
+-- ngx.var.request_id mints a fresh value. Two adjacent reads in one request
+-- return different ids, which quietly made every id the WAF emitted useless for
+-- correlation -- the audit log entry and the webhook for the same request
+-- carried different "request ids", and neither matched the response header.
+-- Read once, cached on ngx.ctx, shared by everything that reports an id.
+local function request_id()
+ local rid = ngx.ctx.waf_request_id
+ if not rid then
+ rid = ngx.var.request_id
+ if not rid or rid == "" then
+ -- Hex, matching $request_id's shape. A decimal timestamp would be
+ -- unqueryable: the route and RBAC patterns for /decisions/{id} are
+ -- hex-only, so an id that is not hex records a decision nobody can
+ -- look up -- the one thing the id exists for.
+ rid = ngx.md5(string.format("%s:%s:%s",
+ ngx.now(), ngx.worker.pid(), ngx.var.connection or "0"))
+ end
+ ngx.ctx.waf_request_id = rid
+ end
+ return rid
+end
+
+-- Append to whichever trace a result carries. Scores added outside the profile
+-- executor -- vhost keywords here, defense lines in the multi-executor -- have
+-- to land in the trace too, or the detail view shows a total that its own
+-- breakdown does not account for.
+local function add_trace_entry(profile_result, entry)
+ if type(profile_result) ~= "table" then return end
+
+ if type(profile_result.trace) == "table" then
+ table.insert(profile_result.trace, entry)
+ return
+ end
+
+ -- Multi-profile result: no top-level trace, so carry these in a list the
+ -- flattener folds in alongside the per-profile ones.
+ profile_result.extra_trace = profile_result.extra_trace or {}
+ table.insert(profile_result.extra_trace, entry)
+end
+
-- Keep a would-block decision so "what happens if I switch this to blocking?"
-- becomes a query rather than a log grep.
--
@@ -72,6 +117,38 @@ local function get_shadow_recorder()
return _shadow_recorder
end
+-- The executor returns a per-node trace per profile. Flatten them into one list
+-- so a detail view can read the decision top to bottom without knowing how many
+-- profiles ran.
+collect_trace = function(profile_result)
+ if type(profile_result) ~= "table" then return nil end
+
+ -- Single-profile results carry the trace directly; multi-profile results
+ -- nest one per profile.
+ if profile_result.trace then
+ return profile_result.trace
+ end
+
+ local results = profile_result.profile_results
+ if type(results) ~= "table" then return profile_result.extra_trace end
+
+ local flat = {}
+ for profile_id, r in pairs(results) do
+ if type(r) == "table" and type(r.trace) == "table" then
+ for _, entry in ipairs(r.trace) do
+ if type(entry) == "table" then
+ entry.profile = profile_id
+ flat[#flat + 1] = entry
+ end
+ end
+ end
+ end
+ for _, entry in ipairs(profile_result.extra_trace or {}) do
+ flat[#flat + 1] = entry
+ end
+ return flat
+end
+
local function record_shadow_decision(summary, client_ip, host, path, method, profile_result)
local shadow_recorder = get_shadow_recorder()
if not shadow_recorder then
@@ -97,7 +174,7 @@ local function audit_log(event_type, event_data)
local log_entry = {
["@timestamp"] = os.date("!%Y-%m-%dT%H:%M:%SZ"),
event_type = event_type,
- request_id = ngx.var.request_id or tostring(ngx.now()),
+ request_id = request_id(),
client_ip = trusted_proxies.get_client_ip(), -- F01: Use secure IP extraction
host = ngx.var.http_host or ngx.var.host,
path = ngx.var.uri,
@@ -344,6 +421,41 @@ local function detect_field_anomalies(form_data, security_settings, ignore_field
end
-- Process incoming request
+--- Called from log_by_lua. Records the decision stashed during the access
+--- phase, with the status the client actually received attached.
+function _M.log_decision()
+ local decision = ngx.ctx.waf_decision
+ if not decision then
+ return
+ end
+
+ local ok, recorder = pcall(require, "decision_recorder")
+ if not ok or not recorder then
+ return
+ end
+
+ local status = ngx.status
+ -- What happened, from the response rather than from what the pipeline
+ -- intended: monitoring mode returns 200 on a verdict of "block", and the
+ -- record has to say the request was allowed through.
+ local action
+ if status == 403 then
+ action = "blocked"
+ elseif status == 429 then
+ action = "tarpit"
+ elseif ngx.ctx.waf_captcha_challenged then
+ action = "challenged"
+ elseif decision.profile_action == "block" then
+ action = "would_block"
+ else
+ action = "allowed"
+ end
+
+ decision.action = action
+ decision.status = status
+ pcall(recorder.record, decision)
+end
+
function _M.process_request()
local method = ngx.req.get_method()
local path = ngx.var.uri
@@ -620,6 +732,12 @@ function _M.process_request()
profile_result.action = "block"
profile_result.blocked_by = profile_result.blocked_by or {}
table.insert(profile_result.blocked_by, "additional_keyword")
+ add_trace_entry(profile_result, {
+ defense = "additional_keyword",
+ score = 0,
+ blocked = true,
+ flags = { "vhost:add_block:" .. kw },
+ })
ngx.log(ngx.INFO, "ADDITIONAL_KEYWORD_BLOCK: keyword=", kw)
end
end
@@ -633,6 +751,11 @@ function _M.process_request()
checked_flagged[kw_lower] = true
profile_result.score = (profile_result.score or 0) + kw_score
table.insert(profile_result.flags, "vhost:add_flag:" .. kw)
+ add_trace_entry(profile_result, {
+ defense = "additional_keyword",
+ score = kw_score,
+ flags = { "vhost:add_flag:" .. kw },
+ })
end
end
end
@@ -681,6 +804,35 @@ function _M.process_request()
end
end
+ -- Stash the verdict for the log phase. Recording happens there, not in
+ -- the branches below, because there are six of them -- block, captcha,
+ -- tarpit, flag, monitor, allow -- and covering five is the failure this
+ -- feature exists to avoid. The log phase sees exactly one outcome per
+ -- request, and the real one.
+ -- Returned on every response, not gated behind WAF_EXPOSE_HEADERS. It is
+ -- the handle support needs to look a decision up, and it reveals nothing
+ -- about the verdict -- unlike the score and flag headers, which is what
+ -- that flag exists to keep in.
+ ngx.header["X-WAF-Request-Id"] = request_id()
+
+ ngx.ctx.waf_decision = {
+ request_id = request_id(),
+ vhost_id = summary.vhost_id,
+ endpoint_id = summary.endpoint_id,
+ client_ip = client_ip,
+ host = host,
+ path = path,
+ method = method,
+ user_agent = ngx.var.http_user_agent,
+ mode = summary.mode,
+ score = profile_result.score,
+ flags = profile_result.flags,
+ blocked_by = profile_result.blocked_by,
+ block_reason = profile_result.block_reason,
+ profile_action = profile_result.action,
+ trace = collect_trace(profile_result),
+ }
+
-- Handle profile result actions
if profile_result.action == "block" then
local should_block = vhost_resolver.should_block(context)
@@ -799,6 +951,10 @@ function _M.process_request()
profile_result.score or 0
))
metrics.record_request(summary.vhost_id, summary.endpoint_id, "captcha_challenged", profile_result.score or 0)
+ -- Read by log_decision to record action "challenged".
+ -- Without it a challenge is indistinguishable from a plain
+ -- allow in the decision log.
+ ngx.ctx.waf_captcha_challenged = true
return captcha_handler.serve_challenge(context, nil, "defense_profile", client_ip)
end
end
diff --git a/openresty/lua/webhooks.lua b/openresty/lua/webhooks.lua
index 412ac8e..feed82b 100644
--- a/openresty/lua/webhooks.lua
+++ b/openresty/lua/webhooks.lua
@@ -278,7 +278,14 @@ end
-- F14: Use trusted_proxies for secure IP extraction
function _M.create_event_data(context, extra_data)
local data = {
- request_id = ngx.var.request_id or ngx.now(),
+ -- 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
+ -- Hex string in every case. ngx.now() here emitted a number, so a
+ -- payload's request_id changed type depending on where it came
+ -- from, which is a correlation key that cannot be matched on.
+ or ngx.md5(tostring(ngx.now())),
client_ip = trusted_proxies.get_client_ip(),
host = ngx.var.http_host or ngx.var.host,
path = ngx.var.uri,
diff --git a/openresty/spec/decision_recorder_spec.lua b/openresty/spec/decision_recorder_spec.lua
new file mode 100644
index 0000000..cee5c94
--- /dev/null
+++ b/openresty/spec/decision_recorder_spec.lua
@@ -0,0 +1,193 @@
+--[[
+ The decision log exists to answer "why was this blocked?", so the properties
+ that matter are the ones that keep it answerable and bounded:
+
+ * It must not record everything. A busy site posting valid forms would
+ otherwise fill the buffer with records nobody will look up, and evict the
+ ones somebody will.
+
+ * The trace must stay small without dropping the entries that explain the
+ verdict. A profile with thirty nodes mostly reports zeros; those are
+ noise, and keeping them crowds out the mechanism that actually fired.
+]]
+local helper = require "spec_helper"
+
+describe("decision_recorder", function()
+ local recorder
+
+ local function fake_redis()
+ local calls = { lpush = {}, hincrby = {}, ltrim = 0, expire = 0 }
+ return {
+ calls = calls,
+ lpush = function(_, _, v) table.insert(calls.lpush, v); return 1 end,
+ hincrby = function(_, k, f, n)
+ calls.hincrby[k .. "/" .. f] = (calls.hincrby[k .. "/" .. f] or 0) + n
+ return 1
+ end,
+ ltrim = function() calls.ltrim = calls.ltrim + 1; return true end,
+ expire = function() calls.expire = calls.expire + 1; return true end,
+ }
+ end
+
+ local function a_decision(over)
+ local d = {
+ request_id = "req-1", vhost_id = "site", endpoint_id = "contact",
+ client_ip = "203.0.113.10", host = "example.com", path = "/submit",
+ method = "POST", action = "blocked", status = 403, score = 40,
+ flags = { "kw:viagra" },
+ }
+ for k, v in pairs(over or {}) do d[k] = v end
+ return d
+ end
+
+ before_each(function()
+ helper.install_ngx()
+ helper.stub_external_modules()
+ package.loaded["decision_recorder"] = nil
+ package.loaded["decision_buffer"] = nil
+ recorder = require "decision_recorder"
+ recorder._reset_config()
+ end)
+
+ describe("what is worth keeping", function()
+ it("records anything that was acted on", function()
+ assert.is_true(recorder.should_record("blocked", 0))
+ assert.is_true(recorder.should_record("challenged", 0))
+ assert.is_true(recorder.should_record("tarpit", 0))
+ assert.is_true(recorder.should_record("would_block", 0))
+ end)
+
+ it("skips a clean allowed request", function()
+ -- The common case on a healthy site. Recording it would evict the
+ -- decisions somebody actually needs to look up.
+ assert.is_false(recorder.should_record("allowed", 0))
+ end)
+
+ it("keeps an allowed request that scored, so a near-miss is explicable", function()
+ assert.is_true(recorder.should_record("allowed", 5))
+ end)
+ end)
+
+ describe("recording", function()
+ it("buffers a decision without touching Redis", function()
+ assert.is_true(recorder.record(a_decision()))
+ assert.equals(1, recorder.buffer_depth())
+ end)
+
+ it("ignores anything that is not a decision table", function()
+ assert.is_false(recorder.record(nil))
+ assert.is_false(recorder.record("not a table"))
+ assert.equals(0, recorder.buffer_depth())
+ end)
+
+ it("flushes to Redis and counts by action", function()
+ recorder.record(a_decision())
+ recorder.record(a_decision({ action = "allowed", score = 10 }))
+ local red = fake_redis()
+ assert.equals(2, recorder.flush(red))
+ assert.equals(2, #red.calls.lpush)
+ assert.equals(1, red.calls.hincrby["waf:decisions:stats/action:blocked"])
+ assert.equals(1, red.calls.hincrby["waf:decisions:stats/action:allowed"])
+ assert.equals(2, red.calls.hincrby["waf:decisions:stats/total"])
+ end)
+
+ it("clips a hostile path so one request cannot bloat the log", function()
+ recorder.record(a_decision({ path = string.rep("A", 5000) }))
+ local red = fake_redis()
+ recorder.flush(red)
+ assert.is_true(#red.calls.lpush[1] < 2000)
+ end)
+ end)
+
+ describe("empty lists are omitted, not encoded as objects", function()
+ -- cjson encodes an empty Lua table as {}, not []. The UI writes
+ -- `flags ?? []`, which does not catch {} -- it is neither null nor
+ -- undefined -- so .slice() on it is undefined and the page crashes.
+ -- Omitting the field is what keeps the optional-array contract true.
+ local function stored(decision)
+ recorder.record(decision)
+ local red = fake_redis()
+ recorder.flush(red)
+ return red.calls.lpush[1]
+ end
+
+ it("omits an empty flags list rather than storing {}", function()
+ local raw = stored(a_decision({ flags = {} }))
+ assert.is_nil(raw:find('"flags":{}', 1, true),
+ "an empty flags list must not be stored as a JSON object")
+ local cjson = require "cjson.safe"
+ assert.is_nil(cjson.decode(raw).flags)
+ end)
+
+ it("omits an empty blocked_by list", function()
+ local raw = stored(a_decision({ blocked_by = {} }))
+ assert.is_nil(raw:find('"blocked_by":{}', 1, true))
+ end)
+
+ it("omits a trace with nothing significant in it", function()
+ local raw = stored(a_decision({
+ trace = { { node = "n1", defense = "honeypot", score = 0 } },
+ }))
+ assert.is_nil(raw:find('"trace":{}', 1, true))
+ local cjson = require "cjson.safe"
+ assert.is_nil(cjson.decode(raw).trace)
+ end)
+
+ it("still encodes a populated list as an array", function()
+ local raw = stored(a_decision({ flags = { "kw:viagra" } }))
+ assert.is_not_nil(raw:find('"flags":["kw:viagra"]', 1, true))
+ end)
+ end)
+
+ describe("trace compaction", function()
+ local function traced(trace)
+ recorder.record(a_decision({ trace = trace }))
+ local red = fake_redis()
+ recorder.flush(red)
+ local cjson = require "cjson.safe"
+ return cjson.decode(red.calls.lpush[1]).trace
+ end
+
+ it("drops nodes that contributed nothing", function()
+ local kept = traced({
+ { node = "n1", defense = "honeypot", score = 0 },
+ { node = "n2", defense = "keyword_filter", score = 30, flags = { "kw:viagra" } },
+ { node = "n3", defense = "geoip", score = 0 },
+ })
+ assert.equals(1, #kept)
+ assert.equals("keyword_filter", kept[1].defense)
+ end)
+
+ it("keeps a node that blocked without scoring", function()
+ -- result_blocked carries score 0, so a score-only filter would throw
+ -- away the very node that produced the verdict.
+ local kept = traced({ { node = "n1", defense = "keyword_filter", score = 0, blocked = true } })
+ assert.equals(1, #kept)
+ assert.is_true(kept[1].blocked)
+ end)
+
+ it("keeps a node whose detections were suppressed", function()
+ -- "Fired but suppressed" and "never fired" look identical without
+ -- this, and they are the two different answers to "why did this get
+ -- through?".
+ local kept = traced({
+ { node = "n1", defense = "keyword_filter", score = 0, suppressed = { "kw:viagra" } },
+ })
+ assert.equals(1, #kept)
+ assert.same({ "kw:viagra" }, kept[1].suppressed)
+ end)
+
+ it("caps a runaway trace", function()
+ local big = {}
+ for i = 1, 100 do
+ big[i] = { node = "n" .. i, defense = "d", score = 1, flags = { "f" } }
+ end
+ assert.is_true(#traced(big) <= 20)
+ end)
+
+ it("survives a malformed trace rather than erroring", function()
+ local kept = traced({ 42, "nonsense", { node = "n", defense = "d", score = 5 } })
+ assert.equals(1, #kept)
+ end)
+ end)
+end)
diff --git a/scripts/check-api-contract.py b/scripts/check-api-contract.py
index 25e6e7e..665650b 100755
--- a/scripts/check-api-contract.py
+++ b/scripts/check-api-contract.py
@@ -113,6 +113,7 @@ def main():
base = "/" + base.split("/", 3)[3] if len(base.split("/", 3)) > 3 else ""
failures, checked = [], 0
+ skipped = []
for path, methods in spec["paths"].items():
if "get" not in methods:
continue
@@ -123,6 +124,17 @@ def main():
example_query = methods["get"].get("x-contract-example-query")
if example_query:
url = f"{url}?{example_query}"
+
+ # A path template with no fixed example -- a request id, say -- cannot be
+ # fetched. Skip it, but print it: a check that silently covers less than
+ # it appears to is worse than one that admits the gap.
+ skip_reason = methods["get"].get("x-contract-skip")
+ if skip_reason:
+ skipped.append(f"GET {path}: {skip_reason}")
+ continue
+ if "{" in path:
+ skipped.append(f"GET {path}: templated path with no x-contract-skip reason given")
+ continue
try:
status, raw, _ = request(url, cookie=cookie)
except urllib.error.HTTPError as exc:
@@ -153,6 +165,10 @@ def main():
print("API contract check")
print("==================")
print(f" endpoints validated : {checked}")
+ if skipped:
+ print(f" endpoints skipped : {len(skipped)}")
+ for entry in skipped:
+ print(f" - {entry}")
for f in failures:
print(f" MISMATCH : {f}")