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>.score
  • txn.risk.<slug>.rules_hit
  • txn.risk.<slug>.rules_hit_count
  • txn.risk.<slug>.hit_density

Also sets backward-compat aliases (txn.risk.score, txn.risk.rules_hit, etc.) equal to the default ruleset’s values.

Why three phases?

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:

RulesetPurpose
DefaultGeneral traffic scoring
HumanBrowser anomaly detection
APIProgrammatic client scoring
MobileMobile 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:

PropertyDescription
NameDisplay name
ExpressioncoreX expression language (same as Security Rules)
PointsSigned integer (positive = risk, negative = trust)
Categoryprotocol, headers, geo, behavioral, list, trust, custom
Listener scopinglistener_ids (empty = all listeners)
PriorityDrag to reorder
LogWhen 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.

Negative points for trust signals

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:

FieldTypeDescription
keep_alivebooleanHTTP/2+ or Connection: keep-alive
user_agent_lengthintegerLength of User-Agent header
request_hourinteger 0-23UTC hour of request
geo_lang_mismatchbooleanGeoIP country not in Accept-Language
cipher_countintegerFrom JA4 fingerprint (positions 5-6)
ext_countintegerFrom JA4 fingerprint (positions 7-8)
uri_lengthintegerPath + query length
param_countintegerCount of query parameters
version_numericintegerHTTP version (0.9=9, 1.0=10, 1.1=11, 2.0=20, 3.0=30)
timezone_mismatchbooleanClient 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:

FieldDescription
risk.scoreDefault ruleset’s score (0-99)
risk.rules_hitComma-separated matched rule names
risk.rules_hit_countNumber of matched rules
risk.hit_densityHit density percentage
risk.<slug>.scorePer-ruleset score
risk.<slug>.rules_hitPer-ruleset matched rules
risk.<slug>.rules_hit_countPer-ruleset match count
risk.<slug>.hit_densityPer-ruleset density

Prerequisites

Feature dependencies

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

  1. Navigate to Security > Risk Scoring
  2. Click Seed Baseline Rulesets (if not already seeded)
  3. Select a ruleset tab (Default, Human, API, Mobile)
  4. Click Add Rule:
    • Name: long-uri
    • Expression: http.request.uri.length > 1024
    • Points: 3
    • Category: behavioral
  5. Check the score budget bar — it should show the updated sum
  6. Click Apply Changes
  7. 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

  1. Check the score in access logs — the risk_score field appears in JSON access logs
  2. Verify txn vars — add risk_score to your log format and check the values
  3. Test with known-good requests:
    curl -k https://localhost/
  4. 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