RFCs

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?

RFC 0002Fraud and securityIn designWritten 2026-10-20

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

HeapDBCommon point of compromise
EntityA card (one PAN or token), fraud or clean.
EventsLegitimate authorizations and settled purchases (type txn), one event per transaction.
Featuresmerchant:<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 kernelsfirst_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.
Leakleak=0.3: fraud with no common point in the window, such as lost or stolen cards, phishing and account takeover.
RulesNone 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=1e6 makes each card borrow the whole portfolio's evidence, so the per-card ranking reflects the cluster.
  • Across the cluster: top_causes_group over the fraud cards gives one merged list of the transactions that best explain them. The example uses link_report over 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-28
Show 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" above

Evaluation

  • 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?