Common point of compromise
Given the first cards that report fraud, which merchant, terminal or website most likely leaked their data, and which other cards exposed there will be next?
Summary
We give a card issuer's fraud team a ranked list of suspect merchants, with credible intervals, built from the legitimate transactions of the cards that later reported fraud. Then we score the cards that used the top suspect during the estimated exposure window but haven't shown fraud yet.
Fraud analysts ask this when fraud claims start to cluster and they need to know where the card data came from. Today they intersect the fraud cards' histories, and grocery chains and gas stations top every list because almost every card shops there. We weigh each transaction by how well its timing fits the compromise-to-fraud lag, and clean cards that shopped at the same merchant count against it. Analysts walk away with a shortlist, an exposure window and a list of cards to review for monitoring or reissue.
Model
| HeapDB | Common point of compromise |
|---|---|
| Entity | A card (one PAN or token), fraud or clean. |
| Events | Legitimate authorizations and settled purchases (type txn), one event per transaction. |
| Features | merchant:<id>, mcc:<category>, channel:card-present or channel:ecommerce; optionally terminal:<id> or atm:<id> to localize a skimmer. |
| Weight (dose) | 1.0 per transaction in the example. Transactions that expose more card data (a stored card on file, a magnetic-stripe fallback) could weigh more. |
| Effects and kernels | first_fraud (first confirmed fraudulent transaction on the card), LogNormalKernel(median=10 * D, sigma=0.6, tail_sigmas=3.0): peak near 7 days, truncated near 60 days. repeatable=False, since a card's first fraud happens once. |
| Leak | leak=0.3: fraud with no common point in the window, such as lost or stolen cards, phishing and account takeover. |
| Rules | None in the example. A rule could down-weight chip-and-PIN card-present transactions, which are hard to skim, or block transactions after the card was reported. |
The kernel is log-normal rather than GammaKernel. Stolen data passes through several steps (collection, resale, a test charge, cash-out), so delays multiply and the right tail is long. Some batches are used weeks after the theft. A gamma kernel with the same mode falls off faster and would miss those late cards.
Learning
- Confirms (
feedback): an investigator confirms the source for a card, for example when the merchant discloses a breach or forensics ties the card to it. The confirmed transaction gets credit and the card's popular-merchant transactions in the window are rejected. In the example, 5 cards get this, starting from the 30th fraud card. - Observed without labels (
observe): every other fraud card. Each transaction in the lag window gets a soft share of credit in proportion to its current score. The leak keeps a share for fraud with no common point. - Settles (
settle): cards that transacted and stayed clean. "The window passed" means the transaction is older than the kernel's support, about 60 days. For the standing query,settle(..., partial=True)charges evidence in proportion to the lag mass already elapsed. A later fraud on that card undoes it. - Priors: the default Beta(1, 4) for every feature.
global_pseudocount=1e6makes each card borrow the whole portfolio's evidence, so the per-card ranking reflects the cluster. - Across the cluster:
top_causes_groupover the fraud cards gives one merged list of the transactions that best explain them. The example useslink_reportover merchant features instead, because investigators want merchants, not single transactions.
Sidecar architecture
The system of record stays the issuer's authorization and settlement data and its fraud case system. Transactions reach HeapDB from the authorization stream or a change-data-capture feed. Fraud claims arrive from the case system as they are confirmed, and investigator findings arrive as feedback. HeapDB writes back the suspect list, the estimated exposure window and per-card risk scores, as a case note and a review queue. It can be rebuilt at any time by replaying transactions and fraud cases from the source.
Worked example
The simulation follows 1500 cards for 38 days at three popular merchants and about 120 smaller ones. One outdoor-gear e-commerce merchant is breached from day 14 to day 28. A similar store with the same category code is often visited on the same day but is innocent.
Output of 0002-common-point-of-compromise.py
SCENARIO
1500 cards, 38 days, 49,744 legitimate transactions
98 cards with fraud so far, 5 with a source confirmed by investigators
fraud cards that used each merchant before their fraud:
trailhead-outfitters 90, freshmart 96, fuelstop 95, megamart 90
STANDING QUERY: SUSPECT MERCHANTS AS FRAUD CARDS ARRIVE
10 cards, day 21.2: trailhead-outfitters #121 peak-supply #122 of 125
25 cards, day 24.5: trailhead-outfitters #121 peak-supply #122 of 125
35 cards, day 26.6: trailhead-outfitters #1 peak-supply #122 of 125
45 cards, day 28.4: trailhead-outfitters #1 peak-supply #122 of 125
98 cards, day 38.0: trailhead-outfitters #1 peak-supply #113 of 125
ONE CARD, EXPLAINED (card-0055: data stolen day 27.6, first fraud day 33.5)
Top causes of 'first_fraud' for card-0055 at t=2026-07-04T11:03:21Z:
1. #35803 txn@2026-06-28T14:05:07Z [channel:ecommerce,mcc:5941,merchant:trailhead-outfitters] w=1 score=0.0711
lag 140.97h -> kernel 0.960 x weight 1 x link 0.074 [noisy-OR of merchant:trailhead-outfitters=0.06 (a=13.40, b=199.76), mcc:5941=0.01 (a=6.47, b=545.73), channel:ecommerce=0.00 (a=2.54, b=8476.16)]
2. #31021 txn@2026-06-24T20:37:56Z [channel:ecommerce,mcc:5941,merchant:peak-supply] w=1 score=0.0175
lag 230.42h -> kernel 0.868 x weight 1 x link 0.020 [noisy-OR of mcc:5941=0.01 (a=6.47, b=545.73), merchant:peak-supply=0.01 (a=2.86, b=341.19), channel:ecommerce=0.00 (a=2.54, b=8476.16)]
3. #13083 txn@2026-06-11T07:32:27Z [channel:ecommerce,mcc:5941,merchant:trailhead-outfitters] w=1 score=0.0101
lag 555.52h -> kernel 0.136 x weight 1 x link 0.074 [noisy-OR of merchant:trailhead-outfitters=0.06 (a=13.40, b=199.76), mcc:5941=0.01 (a=6.47, b=545.73), channel:ecommerce=0.00 (a=2.54, b=8476.16)]
[segments: 3 scanned / 12 pruned / 15 in window; events scored: 10]
ACROSS ALL FRAUD CARDS (merchant features, global links)
Links of 'first_fraud' for (global) (baseline 0.010; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
merchant:trailhead-outfitters 0.063 [0.038, 0.092] 6.02 208.2 13.40 199.76
merchant:m004 0.015 [0.001, 0.040] 1.40 80.0 1.24 83.78
merchant:m062 0.014 [0.001, 0.039] 1.38 88.0 1.34 91.69
merchant:m055 0.014 [0.001, 0.039] 1.37 82.4 1.25 86.20
merchant:m029 0.014 [0.001, 0.037] 1.34 93.7 1.38 97.31
merchant:m107 0.014 [0.001, 0.039] 1.34 79.0 1.17 82.86
merchant:m090 0.013 [0.001, 0.038] 1.29 77.9 1.11 81.75
... merchant:freshmart #125, lift 0.02
... merchant:fuelstop #124, lift 0.02
... merchant:megamart #123, lift 0.04
PROACTIVE: CARDS EXPOSED AT trailhead-outfitters (day 15.3-27.6), NO FRAUD YET
68 exposed cards; 32 flagged at risk >= 0.02 (feature_risk x lag mass still ahead)
fraud later (simulated to day 100):
flagged 10/32 (31%)
exposed, not flagged 9/36 (25%)
all other clean cards 17/1334 (1%)
CHECK
seeded breach trailhead-outfitters ranks #1 of 125 merchants (lift 6.02)
near-miss peak-supply #113 (lift 0.80); popular merchants #125, #124, #123
estimated exposure window day 15.3-27.6 vs seeded day 14-28Show the full script (259 lines)
"""Common point of compromise: where was the card data stolen?
Simulates one card issuer's portfolio: 1,500 cards making legitimate purchases
for 38 days at a grocery chain, a gas station chain, a big online marketplace
and about 120 smaller merchants. Fraud on a card shows up days to weeks after
its data was stolen. HeapDB runs as a sidecar to the fraud case system: every
new fraud card is observe()d, clean cards are settle()d, and a few cards whose
source an investigator confirmed get feedback(). On "today" (day 38) we ask
which merchant is the common point, then score the cards exposed there that
have not shown fraud yet, and check them against the rest of the simulation.
Hidden ground truth (the seeded cause):
* merchant:trailhead-outfitters (e-commerce, mcc:5941) is breached from day 14
to day 28 (June 15-29). Each purchase there in that window is stolen with
probability 0.6. First fraud follows after a log-normal lag (median 10 days,
sigma 0.6): data is sold and used in batches.
* Distractors: almost every card shops at merchant:freshmart (grocery),
merchant:fuelstop (gas) and merchant:megamart (online marketplace). They are
innocent but appear in nearly every fraud card's history.
* Near-miss: merchant:peak-supply sells to the same outdoor customers, uses the
same payment processor and is often visited on the same day. It is innocent.
* Noise: 2% of cards get fraud for unrelated reasons (lost, stolen, phishing)
at random times.
Run:
pip install heapdb
python 0002-common-point-of-compromise.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 = 11
N_CARDS = 1500
NOW = 38 # the investigation runs on day 38
HORIZON = 100 # the simulation keeps running (truth only) to day 100
T0 = 1780272000.0 # 2026-06-01T00:00:00Z; simulated times are seconds after T0
BREACHED = "merchant:trailhead-outfitters"
NEAR_MISS = "merchant:peak-supply"
POPULAR = ["merchant:freshmart", "merchant:fuelstop", "merchant:megamart"]
BREACH = (14 * D, 28 * D)
STEAL = 0.6 # P(card data stolen | purchase at the breached merchant in the window)
BACKGROUND = 0.02 # P(fraud for unrelated reasons) per card over the 100 days
N_CONFIRMED = 5 # fraud cards whose source an investigator confirms
CHECKPOINTS = (10, 25, 35, 45)
FLAG = 0.02 # risk threshold for proactive action on an exposed card
# merchant -> (mcc, channel, visits per day for a typical card)
MERCHANTS = {
"merchant:freshmart": ("mcc:5411", "card-present", 0.25),
"merchant:fuelstop": ("mcc:5541", "card-present", 0.15),
"merchant:megamart": ("mcc:5399", "ecommerce", 0.10),
BREACHED: ("mcc:5941", "ecommerce", 0.0),
NEAR_MISS: ("mcc:5941", "ecommerce", 0.0),
}
MCCS = ["mcc:5812", "mcc:5814", "mcc:5651", "mcc:5732", "mcc:5942", "mcc:7832", "mcc:5912",
"mcc:4121"]
TAIL = [f"merchant:m{i:03d}" for i in range(120)]
for i, m in enumerate(TAIL):
MERCHANTS[m] = (MCCS[i % len(MCCS)], "ecommerce" if i % 3 == 0 else "card-present", 0.0)
def fraud_lag(rng: random.Random) -> float:
return math.exp(rng.gauss(math.log(10 * D), 0.6))
def simulate(rng: random.Random):
"""Return (transactions to HORIZON, first-fraud time per card, theft time per card)."""
txns, first_fraud, stolen = [], {}, {}
for c in range(N_CARDS):
card = f"card-{c:04d}"
outdoor = rng.random() < 0.12 # shops at outdoor stores
favorites = rng.sample(TAIL, 8)
fraud = math.inf
for day in range(HORIZON):
visits = [m for m in POPULAR if rng.random() < MERCHANTS[m][2]]
if rng.random() < 0.35:
visits.append(rng.choice(favorites))
if rng.random() < (0.06 if outdoor else 0.002):
visits.append(BREACHED)
if rng.random() < 0.6: # same-day co-visit: the near-miss
visits.append(NEAR_MISS)
elif rng.random() < (0.05 if outdoor else 0.002):
visits.append(NEAR_MISS)
for m in visits:
t = day * D + rng.uniform(7, 22) * H
mcc, channel, _ = MERCHANTS[m]
txns.append((t, card, {m, mcc, f"channel:{channel}"}))
if m == BREACHED and BREACH[0] <= t < BREACH[1] and rng.random() < STEAL:
stolen.setdefault(card, t)
fraud = min(fraud, t + fraud_lag(rng))
if rng.random() < BACKGROUND:
fraud = min(fraud, rng.uniform(0, HORIZON) * D)
if fraud < HORIZON * D:
first_fraud[card] = fraud
txns.sort(key=lambda x: x[0])
return txns, first_fraud, stolen
def make_db() -> HeapDB:
db = HeapDB(bucket_seconds=2 * D)
# Compromise-to-fraud lag: LogNormal, median 10 days, peak near 7 days, long tail
# (data is resold and used weeks later). Truncated at median * exp(3 sigma), about
# 60 days. One first fraud per card (repeatable=False). global_pseudocount=1e6 lets
# every card borrow the whole portfolio's evidence: the CPP case-control view.
db.register_effect("first_fraud", LogNormalKernel(median=10 * D, sigma=0.6, tail_sigmas=3.0),
leak=0.3, repeatable=False, cause_types={"txn"},
global_pseudocount=1e6)
return db
def merchant_report(db: HeapDB, min_support: float = 20):
return db.link_report("first_fraud", features=[m for m in MERCHANTS], min_support=min_support)
def main() -> None:
rng = random.Random(SEED)
txns, first_fraud, stolen = simulate(rng)
db = make_db()
now = NOW * D
stream = [(t, 0, (card, feats)) for t, card, feats in txns if t < now]
stream += [(t, 1, card) for card, t in first_fraud.items() if t < now]
stream.sort(key=lambda x: (x[0], x[1]))
by_card: dict[str, list] = {}
cases, confirmed, snapshots, day = [], [], [], 0
for t, kind, item in stream:
while t >= (day + 1) * D: # nightly: settle windows that fully passed
day += 1
db.settle(T0 + day * D)
if kind == 0:
card, feats = item
by_card.setdefault(card, []).append(db.insert(card, T0 + t, "txn", feats))
continue
card = item
db.observe(card, "first_fraud", T0 + t)
cases.append(card)
# From case 30 on, investigators confirm the source of a few cards (the merchant
# discloses the breach, or forensics ties the card to it). The popular merchants
# those cards used in the window are cleared at the same time.
visit = [e for e in by_card.get(card, []) if BREACHED in e.features
and BREACH[0] <= e.time - T0 < BREACH[1] and e.time < T0 + t]
if visit and len(cases) >= 30 and len(confirmed) < N_CONFIRMED:
cleared = [e.id for e in by_card[card] if e.features & set(POPULAR)
and 0 < T0 + t - e.time < 30 * D]
db.feedback(card, "first_fraud", T0 + t, confirmed_event_id=visit[-1].id,
rejected_ids=cleared)
confirmed.append(card)
if len(cases) in CHECKPOINTS:
db.settle(T0 + t, partial=True)
feats = [r.feature for r in merchant_report(db, min_support=3)]
snapshots.append((len(cases), t, feats.index(BREACHED) + 1, feats.index(NEAR_MISS) + 1,
len(feats)))
db.settle(T0 + now, partial=True)
rep = merchant_report(db)
feats = [r.feature for r in rep]
snapshots.append((len(cases), now, feats.index(BREACHED) + 1, feats.index(NEAR_MISS) + 1,
len(feats)))
print("SCENARIO")
print(f" {N_CARDS} cards, {NOW} days, {len(stream) - len(cases):,} legitimate transactions")
print(f" {len(cases)} cards with fraud so far, {len(confirmed)} with a source confirmed "
f"by investigators")
share = []
for m in [BREACHED] + POPULAR:
n = sum(any(m in e.features for e in by_card.get(c, []) if e.time < T0 + first_fraud[c])
for c in cases)
share.append(f"{m.split(':')[1]} {n}")
print(f" fraud cards that used each merchant before their fraud:\n {', '.join(share)}")
print()
print("STANDING QUERY: SUSPECT MERCHANTS AS FRAUD CARDS ARRIVE")
for n, t, r_b, r_n, tot in snapshots:
print(f" {n:>3} cards, day {t / D:4.1f}: trailhead-outfitters #{r_b:<3} "
f"peak-supply #{r_n:<3} of {tot}")
# One card whose data was stolen at the breach (truth), with no confirmed source, a
# visit to the near-miss merchant in its window, and the theft visit ranked first.
for pick in sorted(c for c in cases if c in stolen and c not in confirmed):
res = db.top_causes(pick, "first_fraud", T0 + first_fraud[pick], k=3, explain=True)
if (len(res.causes) == 3 and any(NEAR_MISS in c.event.features for c in res)
and abs(res.top.event.time - T0 - stolen[pick]) < 1):
break
print()
print(f"ONE CARD, EXPLAINED ({pick}: data stolen day {stolen[pick] / D:.1f}, "
f"first fraud day {first_fraud[pick] / D:.1f})")
print(format_result(res))
print()
print("ACROSS ALL FRAUD CARDS (merchant features, global links)")
lines = str(rep).splitlines()
print("\n".join(lines[:9]))
for f in POPULAR:
print(f" ... {f} #{feats.index(f) + 1}, lift {rep.row(f).lift:.2f}")
# Exposure window: the middle 80% of the suspect's visits that are the top cause of a
# fraud card (the tails are timing noise and unrelated fraud).
top = feats[0]
tops = []
for c in cases:
best = db.top_causes(c, "first_fraud", T0 + first_fraud[c], k=1).top
if best is not None and top in best.event.features:
tops.append(best.event.time - T0)
tops.sort()
lo, hi = tops[len(tops) // 10], tops[-1 - len(tops) // 10]
kern = db.effect("first_fraud").kernel
exposed, flagged = [], []
for card, evs in by_card.items():
if card in first_fraud and first_fraud[card] < now:
continue
visits = [e for e in evs if top in e.features and lo <= e.time - T0 <= hi]
if not visits:
continue
exposed.append(card)
# Risk still ahead, per visit: learned link (feature_risk) x share of the lag kernel
# not yet elapsed; visits combine by noisy-OR.
miss = 1.0
for e in visits:
miss *= 1.0 - (db.feature_risk("first_fraud", e.features, entity=card, type="txn")
* (1.0 - kern.mass_fraction(T0 + now - e.time)))
if 1.0 - miss >= FLAG:
flagged.append(card)
later = [c for c in first_fraud if first_fraud[c] >= now]
rest = [c for c in by_card if c not in exposed and not (c in first_fraud
and first_fraud[c] < now)]
print()
print(f"PROACTIVE: CARDS EXPOSED AT {top.split(':')[1]} (day {lo / D:.1f}-{hi / D:.1f}), "
"NO FRAUD YET")
print(f" {len(exposed)} exposed cards; {len(flagged)} flagged at risk >= {FLAG} "
"(feature_risk x lag mass still ahead)")
groups = [("flagged", flagged), ("exposed, not flagged", [c for c in exposed if c not in flagged]),
("all other clean cards", rest)]
print(f" fraud later (simulated to day {HORIZON}):")
for name, g in groups:
n = sum(c in later for c in g)
print(f" {name:<24} {n:>3}/{len(g):<5} ({n / len(g):.0%})")
r_b, r_n = feats.index(BREACHED) + 1, feats.index(NEAR_MISS) + 1
print()
print("CHECK")
print(f" seeded breach trailhead-outfitters ranks #{r_b} of {len(feats)} merchants "
f"(lift {rep.row(BREACHED).lift:.2f})")
print(f" near-miss peak-supply #{r_n} (lift {rep.row(NEAR_MISS).lift:.2f}); popular merchants "
+ ", ".join(f"#{feats.index(f) + 1}" for f in POPULAR))
print(f" estimated exposure window day {lo / D:.1f}-{hi / D:.1f} vs seeded "
f"day {BREACH[0] / D:.0f}-{BREACH[1] / D:.0f}")
if r_b != 1 or r_n == 1 or top != BREACHED:
raise SystemExit(1)
if __name__ == "__main__":
main()- Raw counts do not separate the source. Of the 98 fraud cards, 90 used the breached store before their fraud, but 96 used freshmart and 95 used fuelstop. Clean cards that shopped at the popular merchants push them to #125, #124 and #123 of 125.
- The breached store leads with mean link 0.063, 90% credible interval [0.038, 0.092], lift 6.02. The next merchant is at lift 1.40, and its interval starts at 0.001. The near-miss, peak-supply, ends at #113 with lift 0.80.
- The standing query is not informative early. At 10 and 25 fraud cards the breached store is still #121, because most of its victims have not reported fraud yet. It takes over at 35 cards on day 26.6, after the first investigator confirmations.
- The estimated exposure window, day 15.3-27.6, is close to the seeded day 14-28. Among the 68 exposed cards with no fraud yet, 32 are flagged at risk 0.02 or more. Of those, 10/32 (31%) later turn fraudulent, against 9/36 (25%) for exposed cards not flagged and 17/1334 (1%) for all other clean cards.
- Most of the signal is the exposure itself. The per-card score adds only a little on top. Treating every exposed card as at risk is a fair default, and the score orders the review queue.
Run it:
pip install heapdb
python 0002-common-point-of-compromise.py # copy the script from "Show the full script" aboveEvaluation
- Metric: the rank of the confirmed compromised merchant in
link_report, as a function of the number of fraud cards. Secondary: how well the exposure window matches the disclosed breach window, and precision and recall of flagged cards against fraud in the following 90 days. - Baseline: the classic common-point-of-purchase count (share of fraud cards that used each merchant), and the same count normalized by each merchant's share of all cards. HeapDB should rank the source higher, and with fewer fraud cards, because it uses the lag and the clean cards.
- Ground truth: past breaches the issuer confirmed through card-network alerts, merchant disclosures or forensic reports. A second source is synthetic breaches seeded into real, de-identified transaction history.
Limits and risks
- Thin data. One issuer sees only its own cards, often a handful per breach at a small merchant. The credible intervals show this; a wide interval means wait or pool data.
- Legal weight. Naming a merchant as compromised can harm a business and has legal consequences. The ranking is a lead for investigators, not a finding, and stays inside the fraud team.
- Association, not proof. The links are associations under the model. A merchant that shares customers, a processor or a category with the real source can pick up credit. Here the near-miss stays low only because many of its clean customers settle against it.
- Popular-merchant confounding. Settled clean cards control for it, but only if clean cards are in the data. Feeding HeapDB only the fraud cards brings the raw-count problem back.
- Timing variance. Card testing (small trial charges) can show up days before real fraud, and some data is held for months. Card testing should count as the first fraud, and very late fraud falls outside the kernel.
- Human decisions. Reissuing cards, blocking a merchant or notifying a card network are decisions for the fraud team, not for HeapDB.
Prior art
Common point of purchase (CPP) analysis is standard practice. Rippleshot describes it as starting from cards that report fraud and finding where they all transacted before. It then pulls every card exposed there in that window for monitoring, risk scoring and targeted reissue. It notes that one institution's data is often too thin and that consortium data helps. SAS describes the same approach for uncovering a common point of purchase.
HeapDB does not replace CPP tools, consortium data or card-network alerts. It adds a lag-aware ranking that uses clean cards as controls, learns from investigator findings and explains every score. The same "which earlier change explains this" query is used for incidents.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- How should several issuers pool evidence without sharing card data, for example by sharing only merchant-level link counts?
- Should the kernel be learned per fraud channel, since skimmed card-present data and e-commerce data may be used on different schedules?
- How should terminal-level and merchant-level features be combined, so a single skimmed terminal does not spread its credit across a whole chain?
- What threshold on the credible interval should a merchant pass before it goes to investigators?