An address is not a person

I'm Johannes. I have spent eighteen years on the response side of security, and I build the machinery that turns a breach dump into a number you can defend.

A public research corpus of 49,547 emails contains 710 people.

49,547
emails in
~249,000
raw mentions
710
resolved people
a 350 to 1 reduction, on cooperative data

The same human turns up as a directory row, a signature block, a mangled string in a log, and a first name in someone else's sentence. Count occurrences and you count that person dozens of times. That gap, a factor of 350, is the whole problem.

Have I Been Pwned will tell you an email address appeared in a breach. It will not tell you how many people are in the dump, and that is not a gap in the tool, it is a deliberate choice: an address is a thing you can check, a person is a thing you have to resolve. The question that decides your notification duty, how many actual humans, lives entirely inside that resolution, and almost nobody does it under a clock.

An address is not a person. This post is about the distance between the two, and about how much of the honest work in a breach is hiding in it.

The question after the question

In an earlier piece I wrote about the moment ninety minutes into a bad breach when the general counsel asks what was actually taken, and the room goes quiet. There is a second quiet, and it comes later.

You have read the dump. You can say, roughly, what is in it. Then someone asks how many people are affected, because that number decides whether you notify everybody or only the exposed, and those are different events. And the honest answer, at that moment, is that you have a pile of records and no idea how many humans they represent.

Records are cheap. People are expensive. Turning one into the other is the job.

Four forms, one human

Here is a single person, as a dump actually presents them.

One person · four surface forms · 17 mentions
Directory row
Roberts, Sally K. - VP Trading - ext 3-4471
Signature block
Sally Roberts | Enron North America | sroberts@enron.com
Access log
usr=s.roberts_vp auth=OK host=NA-TRD-07
Free text
ask Sally (Roberts) to sign off before the desk closes
Resolved person #418 - count 1, not 4

Four surface forms. One person. Count the forms and you report four; count the person and you report one, and to do that you have to decide that all four are the same human, which is a claim you will have to defend.

So the first thing each occurrence has to carry is not a name. It is where it came from. A mention, in my pipeline, is closer to this than to a string:

@dataclass(frozen=True)
class Mention:
    text: str                 # "s.roberts_vp"
    source: str               # "logs/access/2001-11/na-trd-07.log.3"
    span: tuple[int, int]     # byte offsets in that file
    extractor: str            # which extractor + version produced it
    source_sha256: str        # hash of the source bytes, bound at extraction

That is deliberately simplified, but the shape is the point. A mention is a pointer, not a copy. It says: this occurrence is these bytes, at this offset, in this file, pulled by this extractor. The hash is committed to an append-only chain, so if the source is edited underneath you later, the mention no longer verifies. You are not storing "Sally Roberts appeared." You are storing something a stranger can walk back to the exact bytes and check.

Get that right and the count stops being an assertion. Every person in the total is a set of mentions, and every mention is provable to its source. Get it wrong, or skip it, and your number is a story you are asking people to trust.

The collapse, and why it is not a black box

Deciding the four are one is entity resolution, and the temptation is to hand it to a model and accept whatever comes back. For a number you may have to defend to a regulator, that is exactly the wrong instinct, because "the model said so" is not a reason a court accepts.

So the match is a score you can read, not a verdict you cannot:

def match_score(a, b) -> float:
    # each term is a contribution you can point at afterwards
    s  = W_EMAIL   * exact(a.email, b.email)
    s += W_NAME    * phonetic_eq(a.name, b.name)   # soundex/metaphone bucket
    s += W_CONTEXT * shared_context(a, b)          # same thread, desk, file cluster
    return s                                        # >= threshold: same person

The weights are calibrated, not guessed, and the terms are legible. When two different people genuinely share a name, the score stays below the line or flags for a human, and either way you can show which terms fired and which did not. That is the difference between a defensible 710 and a confident 710 that falls over the first time someone pulls on it.

One practical note, because it is where naive implementations die: you cannot compare every mention to every other one. 249,000 mentions is 31 billion pairs. You block first, bucketing by phonetic key and other cheap signals, and only score within buckets. The clever part of entity resolution is mostly deciding what never gets compared.

What is underneath: the count is only as honest as the parse

All of this rides on a step nobody looks at.

Before you can resolve mentions you have to extract them, and before that you have to parse the raw dump: stealer logs, mailbox files, exports in three database dialects, none of it clean. The dangerous failure there is not a crash. A crash you notice. It is the line that does not fit and quietly vanishes:

for line in lines:
    rec = parse(line)
    if rec is None:
        continue        # the line is gone now, and nothing counted it
    records.append(rec)

That continue is where a fifth of your mentions can disappear with no seam to show it. The resolution then runs on a corpus that is silently short, and produces a confident 568 where the truth was 710. Two honest-looking steps, stacked, and the output still looks authoritative.

The fix is boring and I will state it as a principle, because it is one I hold and not one everyone does: count what you could not read. Lines in, records out, and the difference is a number you publish, not a value you drop. Reject to a quarantine you can open, never continue into the void. The count of things you could not parse is the most useful number in the pipeline and the one almost nobody reports.

What I got wrong, and what I still cannot do

I did not start clean. My own early pipeline overwrote its extracted text in place, at a fixed location, every time it re-ran. That silently destroyed the record of which extractor produced which bytes, and worse, it re-introduced credentials a redaction pass had already stripped, because the redaction only ran on a file's first extraction. I had built the exact silent-loss failure I am warning you about, and I found it the way you always find these, by accident, months in. Extraction is versioned now and nothing overwrites, which is the only reason a mention's provenance means anything.

So I am writing this from the scar, not from a clean position.

And the 350 to 1 is clean, cooperative data. That corpus is well-formed and adversary-free. A real dump is dirtier in every dimension and salted by someone who profits from your overcount, so the ratio there is worse and the resolution harder. I have run hostile corpora through as test loads, without an incident clock. I have not run this end to end inside a live incident, with counsel arguing about whether the data may even be loaded while the clock runs, and I will not pretend the test numbers survive that unchanged.

Provenance has a limit worth stating plainly too. It proves where a record came from. It does not prove the record is right. A mention can be perfectly traced to its bytes and still be resolved to the wrong person, and the chain will faithfully preserve that mistake. Provenance makes an error auditable. It does not make it not an error.

What is still unsolved

The count has the same hole everything in breach work has: proving absence.

I can show you the 710. I can show you the mentions behind each one and the bytes behind each mention. What I cannot easily prove is that 710 is complete, that there is no person sitting in a corner of the corpus my extraction never reached, in a format the parser had no category for. "Here is everyone I found, and here is exactly how I found them" is a sentence I can defend. "This is everyone who is in there" is not. The number bounds the floor of the affected, not the ceiling, and I have not seen anyone, vendor or regulator, mark that boundary well.

What to take from this

  • An address is not a person. Counting inboxes counts the same human many times.
  • The number that decides notification is the one after the collapse, not before it.
  • Make every mention carry its origin: this text, these bytes, this file, this extractor. A pointer, not a copy.
  • Resolve with a score you can read, not a verdict you cannot. You may have to show which terms fired.
  • Block before you score, or 249,000 mentions becomes 31 billion comparisons.
  • The count is only as honest as the parse under it. Count what you could not read.
  • Provenance proves origin, never correctness. It makes an error auditable, not absent.
  • Proving the count is complete is unsolved. You can show who you found, never that it was everyone.

If you have built entity resolution that holds up when someone disputes the number, or a way to bound what your extraction missed, I want to hear how it works, because everyone I ask has the same 350 to 1 and the same silent continue somewhere upstream of it.

Turning a dump into a defensible count of real people, with every one traceable back to the bytes it came from, is the problem I work on during the day: that work is here.


Sources

  • Enron email corpus (public research dataset), 49,547 messages as processed. Aggregate figures are pipeline results on that corpus.
  • Troy Hunt, "Here's how I verify data breaches," 2016. https://www.troyhunt.com/heres-how-i-verify-data-breaches/
Previous The dump is a claim, not a receipt Next The most important file is the one that isn't there