spray_check
Detect password-spray sources by IP from Keycloak login events and list the user accounts each source breached.
Instructions
Detect password-spray sources and name the accounts they breached — one rule, one call.
For every EXTERNAL source IP (anything outside the ranges declared in
KEYCLOAK_SITES_INI) seen in LOGIN / LOGIN_ERROR events during the last
hours, compute distinct users and success rate. An IP is a spray source
when unique_users >= min_users AND success_rate < max_success_rate.
Its successful logins are the breach CANDIDATES; whether they may be
called breached depends on the row's confidence.
The breach list is built ONLY from LOGIN events whose source IP is the
flagged IP, inside the window. Every entry carries the evidence tuple
{time, ip, username, user_id, client_id}. A compromised account can
therefore never be reported without an actual login event from the spray
source — do not add names that are not in spray[].breached.
confidence separates a spray from a shared egress (school NAT, home
line, VDI) that merely looks like one by volume. It is "low" — treat the
successes as "verify with the owner", never publish them as breached —
when any of these signals holds: user_success_rate (distinct
users that logged in at least once ÷ distinct users) >=
max_user_success_rate (real sprays sit at 0.0–0.06; a school NAT
with students retyping a mistyped domain sat at 0.47), or
failure_concentration (share of failures on the single most-failing
username) >= max_failure_concentration (one locked-out user retrying
from a shared line produced 0.91), or the IP is in KEYCLOAK_KNOWN_EGRESS.
Only confidence: high rows are a breach verdict. Read
top_failed_users and not_found_domains (domains of usernames that
do not exist — typos of the real domain are humans, not a scraped list)
before writing anything up.
Returns a fixed-shape dict:
window: {hours, since, until} actually scanned.
complete: False if event pagination was cut short (KEYCLOAK_DEADLINE /
KEYCLOAK_MAX_EVENTS). When False, treat the result as a lower bound
and do NOT publish a definitive verdict; narrow hours and retry.
spray: flagged IPs (see external_ips for the row shape), each with
breached = list of evidence tuples; confidence: high rows
first.
external_ips: every external IP with at least min_report_users
distinct users, flagged or not, sorted flagged-first, then
high-confidence first, then by ascending success rate — so
near-misses (e.g. 8 users at 14%) are visible without a second
rule. Row: ip, known_egress, flagged, confidence ("high"/"low"),
signals (list of the reasons for "low"), unique_users,
users_with_success, user_success_rate, failure_concentration,
top_failed_users (up to 5 {username, failures}),
not_found_domains (domain -> count for user_not_found
usernames), attempts, successes, failures, success_rate, errors
(error-code counter; user_not_found mixed with
invalid_user_credentials on many DIFFERENT names indicates a
scraped username list — on the same few names it is a human
retyping), first_seen, last_seen, unresolved_user_ids, breached.
breached_total: number of evidence tuples across all flagged IPs.
breached_low_confidence: how many of those sit on confidence: low
rows (candidates to verify, not breaches).
internal_events_excluded: events dropped because the IP is internal.
resolves_used / resolve_capped: how many GET /users/{id} lookups were
spent resolving success userIds, and whether max_resolves (or
the shared deadline) stopped further lookups. A row's
unresolved_user_ids counts successes keyed by bare userId; if
that is non-zero on a flagged row, unique_users may be
slightly over-counted and breached[].username is the id.
known_egress_configured: whether KEYCLOAK_KNOWN_EGRESS is set. IPs in
those ranges are LABELED known_egress: true, never excluded —
a shared VDI/VPN/proxy egress with many real users is expected to
show a high success rate and usually is not flagged anyway.
Users are keyed by lowercased username on both sides: LOGIN_ERROR carries
details.username; LOGIN usually carries only userId. userIds are
mapped from the fetched events first (any event carrying both fields),
then via GET /users/{id} — only for IPs below the success-rate ceiling
(the only ones that can be flagged), at most max_resolves times, and
never past the shared KEYCLOAK_DEADLINE.
Args:
hours: Look-back window (default 24 — sized for a once-a-day patrol).
min_users: Distinct users an IP must touch to count as a spray (default 10).
max_success_rate: Success-rate ceiling for a spray (default 0.2).
min_report_users: Distinct users an IP needs to appear in
external_ips at all (default 3; clamped to min_users).
max_resolves: Cap on GET /users/{id} lookups per call (default 200).
max_user_success_rate: user_success_rate at or above this marks
the row confidence: low (default 0.3).
max_failure_concentration: failure_concentration at or above this
marks the row confidence: low (default 0.5).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| hours | No | ||
| min_users | No | ||
| max_resolves | No | ||
| max_success_rate | No | ||
| min_report_users | No | ||
| max_user_success_rate | No | ||
| max_failure_concentration | No |