
The Analyst's Stack Part 15 of 22
Entity resolution across 18 sources
Eighteen catalogues report the same incidents. Deciding which reports are the same incident is the entire product.
Why I built it
The question is boring and it comes up constantly: has this organisation, or one of its suppliers, turned up in a ransomware claim or a breach disclosure?
There is no shortage of places to look. Public and subscription catalogues index leak-site posts, breach notifications and statutory filings, and they overlap heavily because most of them watch each other. So you check 5, get 5 overlapping lists, and land on the real problem: one incident appears on all 5 under 3 spellings of the company name, 2 different dates, and — twice — a name with half its letters replaced by asterisks.
By hand that means deciding, row by row, whether Acme Industries, Inc. and ACME INDUSTRIES are one company or two. Fine for 40 rows. The failure at scale is silent: you report 5 incidents where there was one, and nobody catches it.
What it solves
From 18 sources, 340,920 observations collapsed into 120,063 canonical events across 90,565 victims.
Those are measured figures from one dated rebuild, not a standing claim — every rebuild moves them, so the system re-measures at request time and stamps the answer with when it was taken. The ratio is the thing. Nearly 3 raw records per canonical event, and every count you take off the raw corpus is inflated by an amount you cannot see.
After resolution you can ask the question once: one row per incident, with every original record underneath it.

How it works
Collect. One adapter per catalogue, each making exactly one call and returning raw bytes; nothing outside the adapter knows that source’s shape. One hard boundary underneath all of it: nothing is ever collected from threat-actor infrastructure. No leak sites, no Tor. Every source is an index that already did that collection, and a test enforces that rather than a convention.
Normalize. One place in the codebase, and only one. Every record gets a collapsed join key for the victim name, a guarded registrable domain, and a flag saying whether the name arrived masked.
Store. Observations are append-only, one row per record per source, so nothing is overwritten by a later opinion.
Analyze. Two passes. The victim registry decides which records are the same organisation; the clusterer then decides which of that organisation’s records are the same incident. Everything downstream reads the result.
Alert. Watchlist matching on names and domains, deduplicated by content so one incident does not arrive 3 times, then a message to a chat channel.
Present. A dashboard with search, filters and 4 export formats, plus an API.
How I built it
TypeScript and Postgres, small packages that each own one stage. It runs as 3 batch commands — collect, resolve, alert — not as a service, so the interesting failure mode is “the run was wrong” rather than “the daemon is down”.
The 3-year arc matters more than the stack. The 2023 original was a Flask app: a Tor scraper reading leak sites directly, a Postgres table, a dashboard with charts. It worked, and I would not build it that way again, for 2 reasons unrelated to Python.
The first is the boundary. Reading actor infrastructure directly puts you on their timetable and in their logs — and everything that tool went and got, a catalogue had already got. The 2023 tool’s own archive is now 2 of the 18 sources, imported as history, and that is the only form it survives in.
The second is that the old one stored what it had parsed and threw away what it fetched, which made every parser bug permanent: fixing it meant going back for the page, and the page was often gone. So the rebuild keeps the raw bytes — gzipped, content-addressed, named by the hash of what arrived. A parser fix is replayed against stored payloads with no network call at all.
What was hard was picking constants. How many days apart can 2 reports of the same victim be and still be one incident? There is no valley in the data to put a threshold in — the gaps decay smoothly — so any number is a policy choice wearing a discovery’s clothes. The system errs one way, always: prefer a false split over a false merge. A split is visible and correctable. A merge fuses 2 organisations into one row and corrupts the count silently.
$ bun run resolve
$ bun run packages/resolve/src/cli.ts
{
"observationsTotal": 33,
"observationsPlaced": 33,
"eventsTotal": 24,
"victimsTotal": 20,
"skipsTotal": 0,
"skipsByReason": {},
"reductionPct": 27.27272727272727,
"observationsProcessed": 33,
"observationsSkipped": 0,
"eventsCreated": 24,
"eventsJoined": 9,
"victimsCreated": 20,
"ms": 326
}
A look at the code
The join key, rewritten to stand alone. Decoration is thrown away until 2 spellings of one company produce the same string:
const LEGAL_SUFFIX =
/\b(incorporated|corporation|limited|inc|llc|llp|pllc|ltd|gmbh|corp|bv|plc|srl|pty)\b/g;
const MIN_KEY_LENGTH = 4; // anything shorter is an acronym, and acronyms collide
function joinKey(name: string): string | null {
const key = name
.normalize("NFKC")
.toLowerCase()
.replace(/^www\./, "")
.replace(LEGAL_SUFFIX, " ")
.replace(/[^a-z0-9]+/g, ""); // punctuation, spaces, everything
return key.length >= MIN_KEY_LENGTH ? key : null;
}
joinKey("Acme Industries, Inc."); // 'acmeindustries'
joinKey("ACME INDUSTRIES LIMITED"); // 'acmeindustries'
joinKey("AIL"); // null — too short to join on
Three words are missing from that suffix list: co, group and sa. Stripping them was measured and rejected — between them they collided nearly 200 pairs of organisations that merely shared a first word.
Two records agreeing on a victim is not yet one incident. The merge test needs a date too, and how far apart the dates may be depends on how strong the match was:
const DOMAIN_MATCH_WINDOW_DAYS = 90; // domain + actor: a strong key
const NAME_MATCH_WINDOW_DAYS = 30; // name + actor: riskier, so narrower
function isSameEvent(record, existingEvent): boolean {
if (!record.eventDate) return false; // no usable date, never merges
if (existingEvent.victimId !== record.victimId) return false;
if (existingEvent.actorId !== record.actorId) return false;
const matchedOnDomain = Boolean(record.domain);
if (!matchedOnDomain && (record.nameIsMasked || !record.nameKey)) return false;
const gapDays =
Math.abs(+existingEvent.eventDate - +record.eventDate) / 86_400_000;
return gapDays <= (matchedOnDomain ? DOMAIN_MATCH_WINDOW_DAYS : NAME_MATCH_WINDOW_DAYS);
}
A record with a masked name and no domain never joins on the name. Sources restating the same masked title agree about the mask, not about the victim.
What it can’t do
The dates are imprecise, and the corpus says so
More sources is not more certain
It splits when it cannot tell

And one that does not fit in a box: none of this verifies anything. A canonical event is an accurate summary of what 18 catalogues said. Whether the incident happened as claimed is a separate question, and it is still yours.
Where this sits in the series
This is stage 4 of the 6-stage spine carrying almost the whole weight of a system. Collection is ordinary and the interface is a search box; the value is entirely in the narrowing.
It is also the argument for stage 2. The first workflow post says normalising feels like ceremony with one source and becomes unavoidable with 5. At 18 it is not a stage. It is the product.

Storing and searching what you collect is the method behind this post: the same resolution problem taught generally, with the observation-versus-interpretation split underneath it. If this post is the case, that one is the argument.