Security alert triage
When an account takeover is confirmed, which earlier event was most likely the entry point, and how risky are new events that look like it?
Summary
We give a security operations team a ranked, explained list of the events most likely to be the entry point of a confirmed account takeover. Then we score new events by how closely they resemble learned entry points.
Analysts ask this right after a takeover or intrusion is confirmed. They have hundreds of login, consent, reset and mail events to read, and most look odd but are benign. With HeapDB they start from the top suspects, record a verdict, and every verdict sharpens the next ranking and the risk score on fresh events.
Model
| HeapDB | Fraud and security |
|---|---|
| Entity | An account (identity). Takeovers are confirmed per account, and the entry vectors here (consent grants, resets, MFA prompts) act on the identity, not a machine. Endpoint intrusions would use a host as a second entity, with its own effect. |
| Events | login, oauth_grant, password_reset, mail (phishing clicks reported by the mail gateway), inbox_rule, token_use. |
| Features | login:new_device, geo:new_country, asn:hosting, mfa:none, mfa:push, mfa:totp, push_fatigue; oauth:<app>, scope:mail.read; reset:email, reset:helpdesk; phish_click; rule:forward_external; token:new_ip. |
| Weight (dose) | 1.0 for every event in the example. A team could weight grants by the breadth of scopes requested. |
| Effects and kernels | account_takeover with LogNormalKernel(median=6 * H, sigma=1.0, tail_sigmas=3.0). It peaks near two hours and has support of about five days. Detection lags from minutes to days, with most confirmations within a working day. |
| Leak | leak=0.05: takeovers that no event in the window explains, such as reused credentials used directly on a service HeapDB does not see. |
| Rules | post-entry symptom x0.3 on inbox_rule and token_use. These are what attackers do after entry, so they are down-weighted as entry points but kept visible. |
Links start from default_prior=(1, 19): almost every event is benign.
Learning
- Feedback (analyst verdict). When an analyst closes an incident with a verdict,
feedback(account, "account_takeover", at, confirmed_event_id=..., rejected_ids=...)confirms the entry event and rejects the other suspects shown. - Observe (no label). Incidents confirmed without a verdict, and auto-closed alerts later tied to a takeover, call
observe(account, "account_takeover", at). Events in the window get soft credit and the leak takes the rest. - Settle.
settle(until, "account_takeover", entity=account)adds negative evidence to events of accounts with no takeover. "The window passed" meansuntilis later than the event time plus the kernel support (about five days). - Priors. A sceptical default prior per feature. Each account borrows from the global layer, so a new account inherits what the team has learned.
Sidecar architecture
The system of record stays the identity provider logs, the mail gateway and the case management system. Events reach HeapDB from the existing log stream or SIEM export; verdicts come from case closure through an outbox. A periodic job calls settle with the stream's watermark.
HeapDB writes back the top suspects per incident and a feature_risk score for new events, as enrichment on the alert. It holds no state that cannot be rebuilt by replaying the source logs and case verdicts.
Worked example
A synthetic company of 1,500 accounts faces two real entry vectors: a consent-phishing app, oauth:docs-viewer-pro, and social-engineered helpdesk password resets. Travel logins from new countries and new devices after phone upgrades are common and benign.
Output of 0009-security-alert-triage.py
SCENARIO
42,315 identity events for 1,500 accounts over 30 days
113 takeovers confirmed (72 via a seeded vector, 41 background)
learning: 87 analyst verdicts (feedback), 26 unlabelled (observe), benign accounts settled
ONE CASE, EXPLAINED (after learning; this incident got no analyst verdict)
Top causes of 'account_takeover' for acct-0943 at t=656034:
1. #26622 oauth_grant@644109 [oauth:docs-viewer-pro,scope:mail.read] w=1 score=0.1542
lag 3.31h -> kernel 0.921 x weight 1 x link 0.167 [noisy-OR of oauth:docs-viewer-pro+scope:mail.read=0.17 inseparable]
2. #26623 login@646494 [asn:hosting,geo:new_country,login,mfa:none] w=1 score=0.1398
lag 2.65h -> kernel 0.983 x weight 1 x link 0.142 [noisy-OR of geo:new_country=0.04 (a=1.11, b=24.23), asn:hosting=0.04 (a=1.11, b=25.21), mfa:none=0.04 (a=1.09, b=25.23), login=0.02 (a=1.11, b=47.26)]
3. #26621 mail@643562 [phish_click] w=1 score=0.0390
lag 3.46h -> kernel 0.903 x weight 1 x link 0.043 [noisy-OR of phish_click=0.04 (a=1.08, b=23.98)]
4. #26624 token_use@648879 [token:new_ip] w=1 score=0.0129
lag 1.99h -> kernel 0.995 x weight 1 x link 0.043 x rules post-entry symptom x0.3 [noisy-OR of token:new_ip=0.04 (a=1.08, b=23.94)]
[segments: 2 scanned / 3 pruned / 5 in window; events scored: 6]
ACROSS ALL CASES (global links, top 8 by strength)
feature mean 90% CI lift n
oauth:docs-viewer-pro 0.251 [0.182, 0.325] 7.81 77.8
reset:helpdesk 0.161 [0.106, 0.223] 5.01 84.8
scope:mail.read 0.094 [0.066, 0.125] 2.92 241.9
token:new_ip 0.021 [0.003, 0.050] 0.65 66.8
rule:forward_external 0.017 [0.003, 0.041] 0.53 95.4
phish_click 0.010 [0.001, 0.027] 0.32 122.5
oauth:calendar-sync 0.008 [0.000, 0.024] 0.25 103.0
oauth:notes-app 0.008 [0.000, 0.024] 0.25 107.1
RISK OF NEW EVENTS (feature_risk, global view, before any lag)
fresh grant to docs-viewer-pro 0.3209
helpdesk password reset 0.1607
reported phishing click 0.0102
grant to a known app 0.1001
login from a new country 0.0050
login from a new device 0.0043
CHECK
seeded vectors rank oauth:docs-viewer-pro #1, reset:helpdesk #2 of 19 features
near-miss phish_click ranks #6: it precedes the malicious grant, analysts reject it
true entry event is the #1 suspect in 24/72 seeded incidents, top-3 in 70/72Show the full script (215 lines)
"""Security alert triage: which earlier event was the entry point of an account takeover?
Simulates 1,500 employee accounts over 30 days of identity telemetry: logins (new device,
new country, hosting ASN, MFA method, MFA push fatigue), OAuth consent grants, password
resets, phishing clicks reported by the mail gateway, new inbox rules and token use from a
new IP. Some accounts are taken over; the takeover is confirmed minutes to days later.
Hidden ground truth (the seeded entry vectors, per event):
* oauth:docs-viewer-pro -- a consent-phishing app; a grant leads to takeover with p=0.55
* reset:helpdesk -- a social-engineered helpdesk reset; p=0.20 (most are legit)
* plus a small background rate of takeovers with no visible entry event
Distractors: travel logins from new countries (common, benign), new devices after phone
upgrades (common, benign). Honest near-miss: the consent-phishing email is often clicked
(phish_click) minutes before the malicious grant, but the click itself does nothing here.
After a takeover the attacker logs in from a hosting ASN, uses tokens from a new IP and adds
a forwarding inbox rule: symptoms that precede the confirmation but are not entry points.
Confounder: the malicious app always asks for scope:mail.read, so that scope picks up
credit and legitimate mail apps that request it inherit some risk.
Learning: analyst verdicts on 70% of incidents -> feedback (confirm the entry event, reject
the other suspects); the rest are confirmed without a verdict -> observe; accounts with no
labelled incident -> settle once their windows have passed.
Run:
pip install heapdb
python 0009-security-alert-triage.py
"""
from __future__ import annotations
import math
import random
from heapdb import HeapDB, LogNormalKernel, format_result
M, H, D = 60.0, 3600.0, 86400.0
SEED = 9
N_ACCOUNTS = 1500
DAYS = 30
EFFECT = "account_takeover"
FAKE_APP = "oauth:docs-viewer-pro"
SEEDED = {FAKE_APP: 0.55, "reset:helpdesk": 0.20}
BACKGROUND = 0.03 # chance an account is taken over with no visible entry event
LABELLED = 0.70 # share of incidents that get an analyst verdict
KERNEL = LogNormalKernel(median=6 * H, sigma=1.0, tail_sigmas=3.0) # support ~5 days
APPS = ["oauth:calendar-sync", "oauth:notes-app", "oauth:mail-merge", "oauth:crm-connector"]
def lag(rng: random.Random) -> float:
while True: # LogNormal lag, median 6h, kept inside the kernel's support
x = math.exp(rng.gauss(math.log(6 * H), 1.0))
if 2 * M < x < KERNEL.support:
return x
def login(rng: random.Random, travel: bool, upgrade_day: float, t: float) -> set[str]:
f = {"login", rng.choice(["mfa:push"] * 6 + ["mfa:totp"] * 3 + ["mfa:none"])}
if travel and rng.random() < 0.5:
f.add("geo:new_country")
if abs(t - upgrade_day) < D and rng.random() < 0.6:
f.add("login:new_device")
if rng.random() < 0.01:
f.add("asn:hosting") # VPNs and cloud desktops
if f & {"mfa:push"} and rng.random() < 0.01:
f.add("push_fatigue")
return f
def simulate(rng: random.Random):
"""Return (events, incidents). events: (acct, t, type, features); incidents: dicts."""
events, incidents = [], []
for a in range(N_ACCOUNTS):
acct = f"acct-{a:04d}"
travel_start = rng.uniform(0, DAYS * D) if rng.random() < 0.25 else -1e9
upgrade = rng.uniform(0, DAYS * D) if rng.random() < 0.15 else -1e9
evs = []
for _ in range(rng.randint(15, 40)):
t = rng.uniform(0, DAYS * D)
evs.append((t, "login", login(rng, abs(t - travel_start - 2 * D) < 2 * D, upgrade, t)))
if rng.random() < 0.3:
evs.append((rng.uniform(0, DAYS * D), "oauth_grant",
{rng.choice(APPS)} | ({"scope:mail.read"} if rng.random() < 0.4 else set())))
if rng.random() < 0.06: # consent-phishing campaign reaches this user
t = rng.uniform(0, DAYS * D)
evs.append((t - rng.uniform(1, 10) * M, "mail", {"phish_click"}))
evs.append((t, "oauth_grant", {FAKE_APP, "scope:mail.read"}))
elif rng.random() < 0.05: # ordinary phishing-test or spam click
evs.append((rng.uniform(0, DAYS * D), "mail", {"phish_click"}))
if rng.random() < 0.15:
evs.append((rng.uniform(0, DAYS * D), "password_reset", {"reset:email"}))
if rng.random() < 0.06:
evs.append((rng.uniform(0, DAYS * D), "password_reset", {"reset:helpdesk"}))
evs.sort(key=lambda e: e[0])
# Who was taken over, and through which event (first one wins).
entry, t_entry = None, math.inf
for i, (t, _typ, feats) in enumerate(evs):
p = max((SEEDED.get(f, 0.0) for f in feats), default=0.0)
if p and rng.random() < p and t < t_entry:
entry, t_entry = i, t
if entry is None and rng.random() < BACKGROUND:
t_entry = rng.uniform(0, DAYS * D)
if math.isfinite(t_entry):
confirm = t_entry + lag(rng)
gap = confirm - t_entry
evs.append((t_entry + 0.2 * gap, "login", {"login", "asn:hosting", "geo:new_country",
"mfa:none"}))
evs.append((t_entry + 0.4 * gap, "token_use", {"token:new_ip"}))
evs.append((t_entry + 0.6 * gap, "inbox_rule", {"rule:forward_external"}))
evs.sort(key=lambda e: e[0])
incidents.append({"acct": acct, "at": confirm, "entry": entry,
"entry_t": t_entry, "labelled": rng.random() < LABELLED})
if rng.random() < 0.03: # benign forwarding rule (personal archive)
evs.append((rng.uniform(0, DAYS * D), "inbox_rule", {"rule:forward_external"}))
events += [(acct, t, typ, feats) for t, typ, feats in evs]
incidents.sort(key=lambda x: x["at"])
return events, incidents
def main() -> None:
rng = random.Random(SEED)
events, incidents = simulate(rng)
db = HeapDB(bucket_seconds=12 * H)
db.register_effect(EFFECT, KERNEL, default_prior=(1, 19), leak=0.05)
# Analyst policy: forwarding rules and token reuse are what attackers do AFTER entry.
db.add_rule(EFFECT, "post-entry symptom", None, 0.3, types={"inbox_rule", "token_use"})
for acct, t, typ, feats in events:
db.insert(acct, t, typ, feats)
n_fb = n_obs = 0
labelled_accts = set()
for inc in incidents:
acct, at = inc["acct"], inc["at"]
true_id = None
if inc["entry"] is not None:
true_id = next(e.id for e in db.events(acct)
if e.time == inc["entry_t"] and e.type in ("oauth_grant", "password_reset"))
if inc["labelled"]:
res = db.top_causes(acct, EFFECT, at, k=5)
rejected = [c.event.id for c in res if c.event.id != true_id]
db.feedback(acct, EFFECT, at, confirmed_event_id=true_id, rejected_ids=rejected)
labelled_accts.add(acct)
n_fb += 1
else:
db.observe(acct, EFFECT, at)
n_obs += 1
inc["true_id"] = true_id
horizon = DAYS * D + KERNEL.support + D
for acct in db.entities:
if acct not in labelled_accts:
db.settle(horizon, EFFECT, entity=acct)
seeded_n = sum(1 for i in incidents if i["entry"] is not None)
print("SCENARIO")
print(f" {len(events):,} identity events for {len(db.entities):,} accounts over {DAYS} days")
print(f" {len(incidents)} takeovers confirmed ({seeded_n} via a seeded vector, "
f"{len(incidents) - seeded_n} background)")
print(f" learning: {n_fb} analyst verdicts (feedback), {n_obs} unlabelled (observe), "
f"benign accounts settled")
# Where does the true entry event rank now, over all seeded incidents?
ranks_true, pick = [], None
for inc in incidents:
if inc["entry"] is None:
continue
res = db.top_causes(inc["acct"], EFFECT, inc["at"], k=5)
ids = [c.event.id for c in res]
tid = inc["true_id"]
ranks_true.append(ids.index(tid) + 1 if tid in ids else 99)
feats = {f for e in db.events(inc["acct"]) for f in e.features}
if (pick is None and not inc["labelled"] and ids and ids[0] == tid
and FAKE_APP in feats and "phish_click" in feats):
pick = inc
print()
print("ONE CASE, EXPLAINED (after learning; this incident got no analyst verdict)")
print(format_result(db.top_causes(pick["acct"], EFFECT, pick["at"], k=4, explain=True)))
print()
print("ACROSS ALL CASES (global links, top 8 by strength)")
rep = db.link_report(EFFECT, min_support=3)
print(f" {'feature':<26} {'mean':>6} {'90% CI':>15} {'lift':>6} {'n':>7}")
for r in list(rep)[:8]:
print(f" {r.feature:<26} {r.strength:>6.3f} [{r.lo:.3f}, {r.hi:.3f}] "
f"{r.lift:>6.2f} {r.n:>7.1f}")
print()
print("RISK OF NEW EVENTS (feature_risk, global view, before any lag)")
new = [
("oauth_grant", {FAKE_APP, "scope:mail.read"}, "fresh grant to docs-viewer-pro"),
("password_reset", {"reset:helpdesk"}, "helpdesk password reset"),
("mail", {"phish_click"}, "reported phishing click"),
("oauth_grant", {"oauth:mail-merge", "scope:mail.read"}, "grant to a known app"),
("login", {"login", "geo:new_country", "mfa:push"}, "login from a new country"),
("login", {"login", "login:new_device", "mfa:totp"}, "login from a new device"),
]
for typ, feats, label in new:
print(f" {label:<32} {db.feature_risk(EFFECT, feats, type=typ):.4f}")
print()
print("CHECK")
order = [r.feature for r in rep]
ranks = {f: order.index(f) + 1 for f in SEEDED}
print(" seeded vectors rank " + ", ".join(f"{f} #{r}" for f, r in ranks.items())
+ f" of {len(order)} features")
near = order.index("phish_click") + 1
print(f" near-miss phish_click ranks #{near}: it precedes the malicious grant, analysts reject it")
n = len(ranks_true)
hit1 = sum(r == 1 for r in ranks_true)
hit3 = sum(r <= 3 for r in ranks_true)
print(f" true entry event is the #1 suspect in {hit1}/{n} seeded incidents, top-3 in {hit3}/{n}")
if sorted(ranks.values()) != [1, 2]:
raise SystemExit(1)
if __name__ == "__main__":
main()- The explained case had no analyst verdict. The malicious grant 3.31h earlier ranks first with score 0.1542, but the attacker's own hosting-ASN login is a close second at 0.1398.
- That near tie is the honest weak point. The true entry event is the #1 suspect in 24/72 seeded incidents and top-3 in 70/72, because post-entry logins sit closer to the confirmation.
- Across all cases,
oauth:docs-viewer-pro(0.251) andreset:helpdesk(0.161) rank #1 and #2 of 19 features.phish_clickranks #6: it precedes the malicious grant, and analysts reject it. - On new events, a fresh grant to the malicious app scores 0.3209 and a helpdesk reset 0.1607. A login from a new country scores 0.0050.
- A grant to a known app scores 0.1001, because it shares
scope:mail.readwith the malicious app. That is a confounder, not a finding.
Run it:
pip install heapdb
python 0009-security-alert-triage.py # copy the script from "Show the full script" aboveEvaluation
- Metric. Hit@1 and hit@3 of the analyst-confirmed entry event among the ranked suspects, measured on incidents held out from learning. For risk scoring, precision at a fixed alert budget per day.
- Baseline. The team's current order: severity of correlation rules, or most recent anomalous event first. HeapDB must beat it on hit@3 without raising the daily alert count.
- Ground truth. Entry points confirmed in closed incident reports and red-team exercises with a known entry vector.
Limits and risks
- Scores are associations under the model, not causal effects. Shared features such as
scope:mail.readspread risk to benign apps. - Attackers adapt. A new app name or a new reset pretext starts at the prior, so learned links age and need review.
- Post-entry activity can outrank the entry point. Analysts must read the explanation, not just the top row.
- Alert fatigue: a
feature_riskscore on every login would flood the queue. Thresholds and budgets belong to the team. - User activity logs are sensitive. HeapDB needs the same access control, retention and audit as the source logs, and
forget_entityfor deletion. - Never lock an account solely on the score. Containment follows the team's policy, and a human decides.
Prior art
SIEM and XDR platforms already link alerts with correlation rules. Attack-path and provenance graph analysis already reconstruct how an intrusion spread across accounts and hosts.
HeapDB complements these tools. It adds a ranked, explained entry-point attribution per incident that learns from analyst verdicts, and reuses those links to score new events.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- Should post-entry symptoms be a separate effect, so they rank as evidence of takeover rather than as entry candidates?
- How fast should learned links decay as attackers change apps and pretexts?
- Should one incident spanning an account and a host be one group query or two linked effects?
- How should verdicts from red-team exercises be weighted against real incidents?