322 lines
13 KiB
Python
322 lines
13 KiB
Python
"""Security alerts: raising them on a failed sign-in, a disabled PIN or an ended session, auditing policy denials,
|
|||
|
|
and the Admin Security tab's list of what happened.
|
||
|
|
|
||
|
|
Raising goes through `notify.raise_event` (security_alert, signin_burst) inside the caller's database work. A
|
||
|
|
failure here is logged and never breaks the sign-in or the request it rides on.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from datetime import timedelta
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from psycopg.rows import dict_row
|
||
|
|
|
||
|
|
from .. import audit
|
||
|
|
from ..auth import ratelimit, secsettings
|
||
|
|
from ..settings import Settings
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
SECURITY_ALERT = "security_alert"
|
||
|
|
SIGNIN_BURST = "signin_burst"
|
||
|
|
ALERT_DEDUPE_MINUTES = 15
|
||
|
|
BURST_DEDUPE_MINUTES = 30
|
||
|
|
BURST_KINDS = ("password", "pin")
|
||
|
|
PIN_FAILURES_MIN = 3
|
||
|
|
ALERTS_MAX = 1000
|
||
|
|
DENIED_DEDUPE_SECONDS = 60.0
|
||
|
|
DENIED_SKIP_CODES = frozenset({"wrong_secret"})
|
||
|
|
|
||
|
|
ALERT_KINDS = (
|
||
|
|
"lockout",
|
||
|
|
"pin_failures",
|
||
|
|
"denied",
|
||
|
|
"stepup_wrong_pin",
|
||
|
|
"admin_change",
|
||
|
|
"it_edit",
|
||
|
|
"integrity",
|
||
|
|
"signin_burst",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def notify(conn: Any, event_type: str, **kw: Any) -> int | None:
|
||
|
|
"""notify.raise_event in a savepoint. A failure is logged and returns None."""
|
||
|
|
try:
|
||
|
|
from ..notify import raise_event
|
||
|
|
|
||
|
|
with conn.transaction():
|
||
|
|
result: int | None = raise_event(conn, event_type, **kw)
|
||
|
|
return result
|
||
|
|
except Exception:
|
||
|
|
log.exception("could not raise %s", event_type)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _recent_audit(conn: Any, action: str, subject: str, minutes: int) -> bool:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT 1 FROM audit_events WHERE action = %s AND subject_type = 'security' AND subject_id = %s"
|
||
|
|
" AND at > now() - make_interval(mins => %s::int) LIMIT 1",
|
||
|
|
(action, subject, max(1, minutes)),
|
||
|
|
).fetchone()
|
||
|
|
return row is not None
|
||
|
|
|
||
|
|
|
||
|
|
def _security_audit(
|
||
|
|
conn: Any, action: str, subject: str, detail: dict[str, Any], *, user_id: Any = None, ip: str | None = None
|
||
|
|
) -> None:
|
||
|
|
audit.append(
|
||
|
|
conn, action=action, user_id=user_id, subject_type="security", subject_id=subject, detail=detail, ip=ip
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def after_failure(
|
||
|
|
conn: Any, settings: Settings, *, account: str, ip: str | None, kind: str, user_id: Any = None
|
||
|
|
) -> None:
|
||
|
|
"""Call after a failed attempt was recorded. Raises security_alert when the account's cooldown starts, and
|
||
|
|
signin_burst when the address limit trips or the failures across accounts reach the burst threshold."""
|
||
|
|
try:
|
||
|
|
with conn.transaction():
|
||
|
|
_after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=user_id)
|
||
|
|
except Exception:
|
||
|
|
log.exception("security alert check failed")
|
||
|
|
|
||
|
|
|
||
|
|
def _after_failure(conn: Any, settings: Settings, *, account: str, ip: str | None, kind: str, user_id: Any) -> None:
|
||
|
|
sec = secsettings.effective(conn, settings)
|
||
|
|
s = sec.settings or settings
|
||
|
|
now = ratelimit.db_now(conn)
|
||
|
|
if account:
|
||
|
|
wait = ratelimit.check(conn, s, accounts=[account], ip=None, now=now)
|
||
|
|
subject = f"account:{account}"
|
||
|
|
if wait is not None and not _recent_audit(conn, "auth.lockout", subject, sec.signin_account_cooldown_minutes):
|
||
|
|
minutes = max(1, (wait + 59) // 60)
|
||
|
|
_security_audit(
|
||
|
|
conn,
|
||
|
|
"auth.lockout",
|
||
|
|
subject,
|
||
|
|
{"account": account, "kind": kind, "minutes": minutes},
|
||
|
|
user_id=user_id,
|
||
|
|
ip=ip,
|
||
|
|
)
|
||
|
|
notify(
|
||
|
|
conn,
|
||
|
|
SECURITY_ALERT,
|
||
|
|
subject=subject,
|
||
|
|
title=f"Sign-in cooldown started for {account}",
|
||
|
|
body=(
|
||
|
|
f"{sec.signin_account_limit} failed attempts on {account} within "
|
||
|
|
f"{sec.signin_account_window_minutes} minutes. Further attempts are refused for {minutes} "
|
||
|
|
f"minutes. Last address: {ip or 'unknown'}."
|
||
|
|
),
|
||
|
|
detail={"account": account, "kind": kind, "ip": ip, "minutes": minutes},
|
||
|
|
dedupe_minutes=ALERT_DEDUPE_MINUTES,
|
||
|
|
)
|
||
|
|
if ip:
|
||
|
|
wait = ratelimit.check(conn, s, accounts=[], ip=ip, now=now)
|
||
|
|
subject = f"ip:{ip}"
|
||
|
|
if wait is not None and not _recent_audit(conn, "auth.signin_burst", subject, BURST_DEDUPE_MINUTES):
|
||
|
|
_security_audit(conn, "auth.signin_burst", subject, {"reason": "ip_limit", "ip": ip}, ip=ip)
|
||
|
|
notify(
|
||
|
|
conn,
|
||
|
|
SIGNIN_BURST,
|
||
|
|
subject=subject,
|
||
|
|
title=f"Sign-in limit reached for address {ip}",
|
||
|
|
body=(
|
||
|
|
f"{sec.signin_ip_limit} failed sign-ins from {ip} within {sec.signin_ip_window_minutes} minutes. "
|
||
|
|
"The address is refused until the count drops."
|
||
|
|
),
|
||
|
|
detail={"reason": "ip_limit", "ip": ip},
|
||
|
|
dedupe_minutes=BURST_DEDUPE_MINUTES,
|
||
|
|
)
|
||
|
|
if kind in BURST_KINDS:
|
||
|
|
n = conn.execute(
|
||
|
|
"SELECT count(*), count(DISTINCT account) FROM signin_attempts WHERE NOT ok"
|
||
|
|
" AND reason IS DISTINCT FROM %s AND kind = ANY(%s) AND at > %s",
|
||
|
|
(ratelimit.RATE_LIMITED, list(BURST_KINDS), now - timedelta(minutes=secsettings.BURST_WINDOW_MINUTES)),
|
||
|
|
).fetchone()
|
||
|
|
failures, accounts = int(n[0]), int(n[1])
|
||
|
|
if failures >= sec.signin_burst_threshold and not _recent_audit(
|
||
|
|
conn, "auth.signin_burst", "burst", BURST_DEDUPE_MINUTES
|
||
|
|
):
|
||
|
|
detail = {"reason": "burst", "failures": failures, "accounts": accounts}
|
||
|
|
_security_audit(conn, "auth.signin_burst", "burst", detail, ip=ip)
|
||
|
|
notify(
|
||
|
|
conn,
|
||
|
|
SIGNIN_BURST,
|
||
|
|
subject="burst",
|
||
|
|
title=f"{failures} failed sign-ins in {secsettings.BURST_WINDOW_MINUTES} minutes",
|
||
|
|
body=(
|
||
|
|
f"{failures} failed sign-ins across {accounts} accounts in the last "
|
||
|
|
f"{secsettings.BURST_WINDOW_MINUTES} minutes (the threshold is {sec.signin_burst_threshold})."
|
||
|
|
),
|
||
|
|
detail=detail,
|
||
|
|
dedupe_minutes=BURST_DEDUPE_MINUTES,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _name(conn: Any, user_id: Any) -> str:
|
||
|
|
row = conn.execute("SELECT display_name, sam_account FROM users WHERE id = %s", (user_id,)).fetchone()
|
||
|
|
return f"{row[0]} ({row[1]})" if row else str(user_id)
|
||
|
|
|
||
|
|
|
||
|
|
def pin_disabled(conn: Any, user_id: Any, failures: int, where: str) -> None:
|
||
|
|
"""A PIN was disabled after too many wrong entries."""
|
||
|
|
name = _name(conn, user_id)
|
||
|
|
notify(
|
||
|
|
conn,
|
||
|
|
SECURITY_ALERT,
|
||
|
|
subject=f"user:{user_id}",
|
||
|
|
title=f"PIN disabled after {failures} wrong entries: {name}",
|
||
|
|
body=f"The PIN of {name} was disabled after {failures} wrong entries ({where}). An Admin can clear it.",
|
||
|
|
detail={"user_id": str(user_id), "failures": failures, "where": where},
|
||
|
|
dedupe_minutes=ALERT_DEDUPE_MINUTES,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def stepup_session_ended(conn: Any, user_id: Any, failures: int, kind: str, ip: str | None) -> None:
|
||
|
|
"""A session ended after too many wrong unlock or signature attempts."""
|
||
|
|
name = _name(conn, user_id)
|
||
|
|
what = "unlock" if kind == "unlock" else "signature"
|
||
|
|
notify(
|
||
|
|
conn,
|
||
|
|
SECURITY_ALERT,
|
||
|
|
subject=f"user:{user_id}",
|
||
|
|
title=f"Session ended after {failures} wrong {what} attempts: {name}",
|
||
|
|
body=f"{name} entered a wrong PIN or password {failures} times at {what}. The session was ended. "
|
||
|
|
f"Address: {ip or 'unknown'}.",
|
||
|
|
detail={"user_id": str(user_id), "failures": failures, "kind": kind, "ip": ip},
|
||
|
|
dedupe_minutes=ALERT_DEDUPE_MINUTES,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- policy denials
|
||
|
|
|
||
|
|
_denied_lock = threading.Lock()
|
||
|
|
_denied_seen: dict[tuple[str, str, str, str], float] = {}
|
||
|
|
|
||
|
|
|
||
|
|
def _denied_due(key: tuple[str, str, str, str]) -> bool:
|
||
|
|
now = time.monotonic()
|
||
|
|
with _denied_lock:
|
||
|
|
last = _denied_seen.get(key)
|
||
|
|
if last is not None and now - last < DENIED_DEDUPE_SECONDS:
|
||
|
|
return False
|
||
|
|
if len(_denied_seen) > 5000:
|
||
|
|
_denied_seen.clear()
|
||
|
|
_denied_seen[key] = now
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def record_denied(settings: Settings, principal: Any, *, method: str, path: str, code: str) -> None:
|
||
|
|
"""Audit `policy.denied` for a 403 refused to a signed-in principal. The method, path and code only (never a
|
||
|
|
body or a query string). The same user, path and code is audited once a minute at most."""
|
||
|
|
if code in DENIED_SKIP_CODES:
|
||
|
|
return
|
||
|
|
key = (str(principal.user_id), method, path, code)
|
||
|
|
if not _denied_due(key):
|
||
|
|
return
|
||
|
|
from .. import db
|
||
|
|
|
||
|
|
try:
|
||
|
|
with db.get_pool(settings).connection(timeout=5.0) as conn:
|
||
|
|
conn.autocommit = True
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action="policy.denied",
|
||
|
|
user_id=principal.user_id,
|
||
|
|
session_id=principal.session_id,
|
||
|
|
role_snapshot=principal.role_snapshot,
|
||
|
|
subject_type="route",
|
||
|
|
subject_id=f"{method} {path}"[:300],
|
||
|
|
detail={"method": method, "path": path[:300], "code": code},
|
||
|
|
ip=principal.ip,
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("could not audit a denial: %s", type(e).__name__)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- the Security tab's list
|
||
|
|
|
||
|
|
|
||
|
|
def _row(kind: str, r: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"at": r["at"],
|
||
|
|
"kind": kind,
|
||
|
|
"user_id": str(r["user_id"]) if r.get("user_id") else None,
|
||
|
|
"display_name": r.get("display_name"),
|
||
|
|
"account": r.get("account"),
|
||
|
|
"ip": r.get("ip"),
|
||
|
|
"detail": r.get("detail") or {},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
AUDIT_ALERTS = (
|
||
|
|
# (kind, WHERE on audit_events e)
|
||
|
|
("lockout", "e.action = 'auth.lockout'"),
|
||
|
|
("signin_burst", "e.action = 'auth.signin_burst'"),
|
||
|
|
("denied", "e.action = 'policy.denied'"),
|
||
|
|
("admin_change", "e.action LIKE 'admin.%%'"),
|
||
|
|
("it_edit", "e.it_reason IS NOT NULL"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
AUDIT_ALERT_SQL = """
|
||
|
|
SELECT e.at, e.user_id, u.display_name, coalesce(e.detail->>'account', u.sam_account) AS account,
|
||
|
|
host(e.ip) AS ip, e.action, e.detail, e.it_reason, e.subject_type, e.subject_id
|
||
|
|
FROM audit_events e LEFT JOIN users u ON u.id = e.user_id WHERE {where} AND e.at > %s
|
||
|
|
ORDER BY e.id DESC LIMIT %s
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def alerts(conn: Any, hours: int) -> list[dict[str, Any]]:
|
||
|
|
"""What happened in the last `hours`, newest first, at most ALERTS_MAX rows."""
|
||
|
|
out: list[dict[str, Any]] = []
|
||
|
|
since = conn.execute("SELECT now() - make_interval(hours => %s::int)", (hours,)).fetchone()[0]
|
||
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
for kind, where in AUDIT_ALERTS:
|
||
|
|
rows = cur.execute(
|
||
|
|
AUDIT_ALERT_SQL.format(where=where),
|
||
|
|
(since, ALERTS_MAX),
|
||
|
|
).fetchall()
|
||
|
|
for r in rows:
|
||
|
|
detail = dict(r["detail"] or {}) if isinstance(r["detail"], dict) else {"value": r["detail"]}
|
||
|
|
detail.setdefault("action", r["action"])
|
||
|
|
if r["subject_type"]:
|
||
|
|
detail.setdefault("subject", f"{r['subject_type']}:{r['subject_id']}")
|
||
|
|
if kind == "it_edit":
|
||
|
|
detail["it_reason"] = r["it_reason"]
|
||
|
|
out.append(_row(kind, {**r, "detail": detail}))
|
||
|
|
rows = cur.execute(
|
||
|
|
"SELECT max(a.at) AS at, a.user_id, u.display_name, u.sam_account AS account, count(*) AS n,"
|
||
|
|
" (array_agg(host(a.ip) ORDER BY a.at DESC))[1] AS ip, array_agg(DISTINCT a.kind) AS kinds"
|
||
|
|
" FROM signin_attempts a LEFT JOIN users u ON u.id = a.user_id"
|
||
|
|
" WHERE NOT a.ok AND a.reason = 'bad_pin' AND a.user_id IS NOT NULL AND a.at > %s"
|
||
|
|
" GROUP BY a.user_id, u.display_name, u.sam_account HAVING count(*) >= %s",
|
||
|
|
(since, PIN_FAILURES_MIN),
|
||
|
|
).fetchall()
|
||
|
|
for r in rows:
|
||
|
|
out.append(_row("pin_failures", {**r, "detail": {"wrong_pins": int(r["n"]), "where": sorted(r["kinds"])}}))
|
||
|
|
rows = cur.execute(
|
||
|
|
"SELECT a.at, a.user_id, u.display_name, u.sam_account AS account, host(a.ip) AS ip, a.kind, a.reason"
|
||
|
|
" FROM signin_attempts a LEFT JOIN users u ON u.id = a.user_id"
|
||
|
|
" WHERE NOT a.ok AND a.kind IN ('unlock', 'signature') AND a.reason IN ('bad_pin', 'bad_password')"
|
||
|
|
" AND a.at > %s ORDER BY a.id DESC LIMIT %s",
|
||
|
|
(since, ALERTS_MAX),
|
||
|
|
).fetchall()
|
||
|
|
for r in rows:
|
||
|
|
out.append(_row("stepup_wrong_pin", {**r, "detail": {"where": r["kind"], "reason": r["reason"]}}))
|
||
|
|
rows = cur.execute(
|
||
|
|
"SELECT f.at, f.kind, f.subject, f.detail, f.run_id FROM integrity_findings f WHERE f.at > %s"
|
||
|
|
" ORDER BY f.id DESC LIMIT %s",
|
||
|
|
(since, ALERTS_MAX),
|
||
|
|
).fetchall()
|
||
|
|
for r in rows:
|
||
|
|
detail = {"finding": r["kind"], "subject": r["subject"], "run_id": r["run_id"], **(r["detail"] or {})}
|
||
|
|
out.append(_row("integrity", {"at": r["at"], "detail": detail}))
|
||
|
|
out.sort(key=lambda x: x["at"], reverse=True)
|
||
|
|
return out[:ALERTS_MAX]
|