Security investigation¶
Two tools narrow "something is wrong" down to "what, exactly" — get_login_failures_by_ip
finds candidate IPs, get_ip_activity gives the full picture for one of them.
Four more tools act on what you find.
From candidate IPs to a full picture¶
get_login_failures_by_ip(date_from, date_to, top=20) ranks source IPs by
login-failure count over a window. This is the starting point, not the
verdict — a high failure count from a single IP is exactly what a shared NAT
exit or a corporate proxy produces on an ordinary day, and a spray attack
distributed across residential proxies can produce a low per-IP count while
still succeeding. Failure count alone under- and over-flags in both
directions.
get_ip_activity(ip_address, event_types="LOGIN,LOGIN_ERROR", date_from, date_to, max_timeline=200)
is what actually answers the question, for one IP. It fully paginates every
matching event before filtering — unlike filtering get_events client-side on
a single page, which can miss activity outside the most recent results — so
the summary is exhaustive over the requested window, not a sample. It returns
structured data:
summary: total events, success/failure counts, unique users, unique clients, first/last seenusers: per-user success/failure breakdown with distinct KeyCloak error codes (invalid_user_credentials,user_temporarily_disabled, …)clients: per-client (SP) success/failure breakdowntimeline: chronological events, capped atmax_timeline
Reading the numbers: spray vs. one person's typo¶
The distinguishing signal is success rate across distinct users, not raw failure count:
- Many users, low success rate (rule of thumb: ≥10 distinct users, <20%
success) from one IP is the shape of a password-spray or
credential-stuffing run. Any user with
success > 0in that IP's breakdown is a compromised account — this is where you stop reading and act. - One user, many failures, eventual success — especially from a residential or mobile IP, at human intervals of tens of seconds to minutes, often trying an ID variant (a personal email address, a typo'd username) — is ordinary password amnesia. It is not an incident.
- Widening the lens to known shared egress ranges (a corporate VPN, a remote-desktop farm) can turn "many users, one IP" from suspicious into routine — and, symmetrically, a low per-IP success rate spread across many IPs that all belong to one hosting or VPS provider is worth the same scrutiny a single noisy IP would get, because that is what a distributed spray looks like from this vantage point.
get_realm_security_defenses shows whether brute-force detection is on and
its thresholds — useful context for judging whether KeyCloak's own defenses
already caught what you are looking at, and get_brute_force_status confirms
whether one specific user is currently locked.
Responding: the four write tools¶
Once get_ip_activity names a compromised account, three tools do the actual
containment, in the order that matters:
set_user_enabled(username, enabled=False)blocks all future logins. It changes only theenabledflag — custom attributes are untouched. It does not end existing sessions: a token already issued stays valid until it expires on its own. When it disables a user with active sessions, the response reports the session count and says to runlogout_usernext — the tool tells you the follow-up step rather than assuming you know it.logout_user(username)removes all of that user's active sessions, forcing re-authentication everywhere. Reports how many sessions it removed; a call against a user with none is a no-op that says so rather than erroring.reset_password(username, password, temporary=False)sets a new password.temporary=Trueforces a change on next login — the right choice for a reset you are handing to the user, not one you intend to use yourself.
reset_passwords_batch(csv_text, temporary=False) takes username,password
per line (CSV) for bulk resets; an empty password field generates one. Built
for a spray incident with several compromised accounts at once, not for
routine use.
None of the four are exercised by the live smoke test (scripts/smoke_test.py)
— they mutate real accounts, and a scheduled test run is not the place for
that. A unit test enforces that they stay skipped there.
Login loops¶
detect_login_loops(date_from, date_to, threshold=10, window_seconds=60, top=20)
flags users who logged in more than threshold times within window_seconds
— the signature of a broken redirect between KeyCloak and a service provider,
not an attack. It scans LOGIN events only, so it is a separate lens from the
failure-based spray detection above.
Deadlines and partial results¶
get_ip_activity, get_login_failures_by_ip, detect_login_loops, and
get_totp_users all pull potentially large amounts of data — a wide date
range or a large realm can mean many pages of events. Two independent limits
protect against that:
KEYCLOAK_DEADLINE(default 45s) bounds wall-clock time per callKEYCLOAK_MAX_EVENTS(default 200,000) bounds how much a single call fetches
How the cutoff is disclosed depends on the tool's return type:
- The text-returning tools (
get_login_failures_by_ip,detect_login_loops,get_login_stats, and others) prefix their output with⚠️ PARTIAL RESULT — stopped early (window too wide / realm too large): the data below is INCOMPLETE. get_ip_activityreturns structured JSON instead of formatted text, so it signals the same condition through a boolean field,events_capped. Whentrue, the whole result —summary,users,clients, andtimelinealike — is incomplete. This is a separate concern fromtruncated, which only means thetimelinelist itself was capped atmax_timelinewhilesummary/users/clients(computed from the full matched set) stayed accurate.
Either way, the data returned is real, just not exhaustive — narrow
date_from to get full coverage instead of a partial one. A tool that
quietly returned a partial result formatted identically to a complete one
would be worse than useless during an investigation: it would look like
"nothing more happened" when the truth is "we stopped looking".