Risk & decision
Engines live
Engines produce findings; they never act. Turning a pile of findings into a single verdict is the job of two small, deterministic engines: risk aggregates, decision chooses.
Risk: one score from many signals
The risk engine folds every finding and every protocol anomaly into one saturating total. Each finding contributes:
- EngineWeight is a per-engine
[256]uint16weight, O(1) to look up and set from policy — disabling an engine is simply weight0. - Confidence scales the contribution, so a low-confidence guess counts for less than a certain hit.
- Contributions are saturating-added up to a ceiling (default 10000), so no single signal can overflow the score and a barrage of weak hits cannot run away.
Protocol anomalies add their own weights directly. The defaults encode how dangerous each framing problem is:
| Anomaly | Weight |
|---|---|
| Content-Length with Transfer-Encoding | 600 |
| Malformed chunk | 600 |
| Chunked not last | 550 |
| Duplicate Content-Length | 500 |
| Obsolete line folding | 500 |
| Space before colon | 350 |
| Duplicate Host | 350 |
| Bare CR | 200 |
| Invalid header name | 200 |
| Non-ASCII request line | 150 |
The result is an Assessment: the total, the finding vs anomaly split, and the
top-contributing engine (for the Attack Explorer).
Decision: score to action
The decision engine maps the total to an Action through a threshold ladder. The defaults:
| Action | Threshold (score ≥) | Effect |
|---|---|---|
| Allow | — | forward normally |
| Monitor | 150 | forward, but record |
| Throttle | 400 | tarpit the connection |
| Challenge | 700 | proof-of-work interstitial |
| Block | 1200 | graceful 403 |
| Drop | 2500 | close hard |
Thresholds are per-policy, so a stricter site can lower them.
When one finding is enough
A ladder built on a sum has a blind spot: an unambiguous injection scores once and
waits for corroboration that never comes. A lone UNION SELECT contributes
850 × 0.90 = 765, well short of Block at 1200, so the strongest evidence the
engines can produce would only ever challenge.
thresholds.decisive (default 700, 0 disables) closes that. When a
single finding's weighted contribution — risk × engine weight × confidence —
clears it, the action is floored at Block regardless of the total. An
unambiguous injection does not become more true by being seen twice.
The bar is deliberately expressed as a weighted contribution rather than a raw
risk value: an engine an operator has down-weighted, or a finding the engine
itself is unsure of, cannot reach it. Findings that are suggestive rather than
conclusive — a single ../ in a redirect parameter, a bare handler name, a
command word spaced away from its separator — are calibrated to stay below it and
contribute to the sum instead.
Shadow vs enforce
The decision carries both the effective action and the action enforcement would have taken:
- In shadow mode (
enforce=false, the default) any action stronger than Monitor is clamped to Monitor. Nothing is blocked; the would-be action is recorded asShadowAction/would. - In enforce mode the effective action is applied.
The decision also records a reason — Clean, Threshold, ProtocolAnomaly
or DecisiveFinding — so a block is always explainable.
Why fixed-point, why saturating
Risk math is integer and saturating on purpose: it is deterministic across platforms (no float drift), allocation-free, and bounded, so an attacker cannot craft input that makes scoring itself expensive or ambiguous.
Status
Risk aggregation (weights, confidence scaling, saturating total, anomaly weights)
and the full decision matrix — including shadow-mode clamping, the decision trace
to telemetry, inspect-before-dial, and graceful 403 on every block — are built
and verified. Turning enforcement on in production is intentionally gated on a
validated false-positive rate measured in shadow first.