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:

contribution = Risk × (EngineWeight / 256) × (Confidence / 100)
  • EngineWeight is a per-engine [256]uint16 weight, O(1) to look up and set from policy — disabling an engine is simply weight 0.
  • 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 as ShadowAction / would.
  • In enforce mode the effective action is applied.

The decision also records a reasonClean, 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.