Risk Scoring
Overview
The Risk Scoring engine assigns signed integer points to incoming requests based on rule matches, producing a 0-99 risk score per ruleset. The score is computed before Security Rules so that Security Rule expressions can reference risk.score, risk.rules_hit, risk.rules_hit_count, and risk.hit_density.
Architecture (Three-Phase Hybrid)
Risk scoring uses a three-phase hybrid architecture that combines Lua metadata derivation, HAProxy condition matching, and Lua score computation:
Phase 1: risk_capture (Lua action)
Derives 10 metadata fields from the request fingerprint (txn.req_fp.*), raw headers, GeoIP, and JA4. Stores them in txn.risk_fp.* variables. This runs early in the request pipeline, before any rule evaluation.
Phase 2: Per-rule match flags
Python translates each risk rule’s expression to a HAProxy condition and emits set-var(txn.risk.match_<id>) bool(1) if {cond}. HAProxy evaluates these conditions natively during request processing, setting boolean match flags for each rule.
Phase 3: risk_compute (Lua action)
Reads the match flags and a generated points table (risk_rules_data.lua), sums points per ruleset, clamps to [0,99], and sets:
txn.risk.<slug>.scoretxn.risk.<slug>.rules_hittxn.risk.<slug>.rules_hit_counttxn.risk.<slug>.hit_density
Also sets backward-compat aliases (txn.risk.score, txn.risk.rules_hit, etc.) equal to the default ruleset’s values.
Splitting the work lets HAProxy’s native ACL engine do fast condition matching (Phase 2) while Lua handles the metadata derivation and score arithmetic (Phases 1 and 3). This keeps per-request overhead minimal.
Rulesets
Multiple rulesets can coexist, each with an independent 99-point budget:
| Ruleset | Purpose |
|---|---|
| Default | General traffic scoring |
| Human | Browser anomaly detection |
| API | Programmatic client scoring |
| Mobile | Mobile app traffic |
The per-ruleset slug becomes the HAProxy variable name (risk.<slug>.score). The default ruleset’s slug is locked as default.
Density-Based Amplification
When hit_multiplier_enabled is on for a ruleset, the engine applies density-based amplification:
hit_density = round(matched_rules / total_enabled_rules * 100)bonus = floor(hit_density * density_weight)final = raw + bonus, capped at 99
This rewards rulesets that match many rules — a request hitting 8 out of 10 rules gets a higher density bonus than one hitting 2 out of 10.
Managing Rulesets
- Create, edit, and delete rulesets from Security > Risk Scoring
- The default ruleset cannot be deleted
- Slugs are auto-generated from the name and locked for the default ruleset
Risk Rules
Each risk rule has:
| Property | Description |
|---|---|
| Name | Display name |
| Expression | coreX expression language (same as Security Rules) |
| Points | Signed integer (positive = risk, negative = trust) |
| Category | protocol, headers, geo, behavioral, list, trust, custom |
| Listener scoping | listener_ids (empty = all listeners) |
| Priority | Drag to reorder |
| Log | When true, the matched rule name is recorded in txn.risk.rules_hit |
Categories are auto-derived from the expression but can be overridden by the user.
Use negative points to reward trust signals. For example, auth.valid -> -15 points reduces the risk score for authenticated requests, making them less likely to trigger challenge rules.
Metadata Fields
The 10 metadata fields derived by risk_capture:
| Field | Type | Description |
|---|---|---|
keep_alive | boolean | HTTP/2+ or Connection: keep-alive |
user_agent_length | integer | Length of User-Agent header |
request_hour | integer 0-23 | UTC hour of request |
geo_lang_mismatch | boolean | GeoIP country not in Accept-Language |
cipher_count | integer | From JA4 fingerprint (positions 5-6) |
ext_count | integer | From JA4 fingerprint (positions 7-8) |
uri_length | integer | Path + query length |
param_count | integer | Count of query parameters |
version_numeric | integer | HTTP version (0.9=9, 1.0=10, 1.1=11, 2.0=20, 3.0=30) |
timezone_mismatch | boolean | Client local hour outside 6am-11pm (GeoIP timezone) |
Score Budget
- Sum of enabled positive-point rules is capped at 99 per ruleset
- Budget enforcement prevents over-allocation
- Negative-point (trust) rules don’t count against the budget
- The budget API shows current sum, remaining, and over-by
Baseline Rulesets (Seeding)
coreX Manager can seed baseline rulesets to provide a starting point:
- 4 rulesets: Default, Human, API, Mobile
- 32 pre-configured rules across all rulesets
- 4 seed security lists:
high_risk_countries(RU, CN, KP, IR, SY, BY, VE, UA)datacenter_asns(10 major hosting ASNs)known_bot_ja4(curl, Python requests fingerprints)ip_blocklist(empty, for manual entries)
- Each ruleset’s positive points sum to exactly 99
- Seeding is idempotent (running it again won’t duplicate rules)
Integration with Security Rules
Risk scoring runs BEFORE Security Rules in the pipeline. The following fields are available in Security Rule expressions:
| Field | Description |
|---|---|
risk.score | Default ruleset’s score (0-99) |
risk.rules_hit | Comma-separated matched rule names |
risk.rules_hit_count | Number of matched rules |
risk.hit_density | Hit density percentage |
risk.<slug>.score | Per-ruleset score |
risk.<slug>.rules_hit | Per-ruleset matched rules |
risk.<slug>.rules_hit_count | Per-ruleset match count |
risk.<slug>.hit_density | Per-ruleset density |
Prerequisites
Risk scoring requires req_fp_enabled (request fingerprinting) to be on. JA4-derived fields (cipher_count, ext_count) require ja4_enabled. GeoIP fields require MaxMind databases (auto-downloaded by the GeoIpDownloader).
Step-by-Step
- Navigate to Security > Risk Scoring
- Click Seed Baseline Rulesets (if not already seeded)
- Select a ruleset tab (Default, Human, API, Mobile)
- Click Add Rule:
- Name:
long-uri - Expression:
http.request.uri.length > 1024 - Points:
3 - Category:
behavioral
- Name:
- Check the score budget bar — it should show the updated sum
- Click Apply Changes
- Create a Security Rule that uses the score:
- Navigate to Security > Security Rules
- Add rule:
risk.score > 50-> action:challenge
Examples
Risk Rules
# Behavioral: long URI
http.request.uri.length > 1024 -> 3 points (behavioral)
# Trust: valid auth
auth.valid -> -15 points (trust)
# List: high-risk country
ip.geoip.country in $geo:high_risk_countries -> 10 points (list)
# Protocol: old HTTP version
http.request.version_numeric < 11 -> 5 points (protocol)
Security Rules Using Risk
# Challenge high-risk requests
risk.score > 50 -> challenge
# Block very high risk
risk.score > 80 -> deny
# Per-ruleset: block anomalous browser traffic
risk.human.score > 60 -> deny
# Combine with auth
risk.score > 40 and not auth.valid -> challenge
Verification
- Check the score in access logs — the
risk_scorefield appears in JSON access logs - Verify txn vars — add
risk_scoreto your log format and check the values - Test with known-good requests:
curl -k https://localhost/ - Test with known-bad requests:
# Long URI (triggers behavioral rule) curl -k "https://localhost/$(python3 -c 'print("a"*2000)')"
Next Steps
- Security Rules — Use risk scores in access control rules
- Security Lists — Create lists referenced by risk rules
- WAF — Combine WAF anomaly scores with risk scores