handle-correlation
Calibrated same-actor scoring for usernames and handles.
Given handles you have already observed — forum accounts, commit authors, Telegram and X profiles, malware config strings, ad network records — decide which ones belong to the same actor, with a probability you can defend.
pip install handle-correlation
handlecorr score --observations case.csv --corpus usernames.txt
What this is, and what it deliberately is not
It scores handles. It does not discover them. There is no enumeration here: nothing in this package queries a platform to ask whether a username exists. Input is a CSV or JSON of observations the analyst collected under their own authority.
That line is where it is for two reasons.
The analytic reason. Enumeration is solved — maigret and Sherlock check
hundreds of sites and do it well. What none of them do is tell you how much a
match is worth. They report that dave exists on 180 sites and that
kr4ken_x99 exists on 3, with no indication that the second result is worth a
thousand times more than the first. The unsolved problem is the scoring, and
scoring is what this does.
The design reason. An engine that takes a bare handle and returns profiles plus a probable email is the same artifact whether the input is a ransomware affiliate or someone's ex-partner. There is no technical signal that distinguishes them. Requiring the analyst to supply observations they already collected keeps the collection decision — and its legal basis — with the person who has the authority to make it, rather than buried inside a library.
If you have an authorized investigation that needs enumeration, run maigret and feed its output in. The interface is a CSV.
The headline property
Handle reuse across eight platforms cannot produce an attribution on its own.
o = [Observation("kr4ken", p) for p in
("github", "telegram", "x", "forum", "gitlab", "npm", "reddit", "keybase")]
a = assess(all_claims(o), corpus.holders)
a.independent_groups # 1
a.band # Band.WEAK
That is the correct answer and most tooling gets it wrong. Eight platforms
showing the same handle is one observation of one naming habit, made once.
Treating it as eight independent confirmations is how a confident false
accusation gets built. Every same-root claim lands in a single correlation
group, so it aggregates as max + log(1+n) rather than summing.
Add one cryptographic binding and it resolves:
o = [Observation("kr4ken", "github", linked={"pgp": FPR}),
Observation("kraken_x", "telegram", linked={"pgp": FPR})]
# independent_groups = 2, band = PROBABLE
A shared PGP fingerprint is a durable identifier the platform published. That is what turns reuse into attribution, and the model makes the difference explicit.
Correlation-point policy
Handle correlation gets a harder floor than the generic model, because handle reuse is simultaneously the commonest signal and the weakest — people pick similar names independently all the time, and an adversary can adopt a target's handle deliberately.
| Points | Level | Meaning |
|---|---|---|
| 0–1 | INSUFFICIENT | Not a lead. Consistent with two unrelated people. |
| 2–3 | LOW | A lead for further collection. Never an attribution. Do not act on it and do not report it as identifying anyone. |
| 4–5 | MODERATE | Only with a durable identifier; otherwise held at LOW. |
| 6+ | HIGH | Requires a durable identifier (key, verified email, avatar hash). |
Points count distinct evidence types, not claims. Eight platforms showing one handle is one point. Two profiles publishing the same PGP key is one point — one identifier corroborated twice, not two independent facts.
hc = correlation_points(all_claims(observations))
hc.points, hc.level.value, hc.caveat
The caveat string is written to be pasted directly into a report.
Worked example
python examples/end_to_end_handles.py
Offline and deterministic. Four scenarios showing where the policy bites, plus a section on why the handle policy and the generic scoring model can disagree — and why the handle policy governs when they do.
Signals
| Signal | Weight | Notes |
|---|---|---|
| Same root after mutation | MODERATE–STRONG | kr4ken / xXkrakenXx / kraken.1988 → one root |
| Homoglyph substitution | MODERATE | Cyrillic а for Latin a is deliberate; raises confidence |
| Platform-published email | STRONG | From a profile you observed, not derived |
| PGP / SSH fingerprint | AUTHORITATIVE | Cryptographic binding |
| Gravatar hash | STRONG | |
| Linked domain | MODERATE | |
| Display name | WEAK | |
| Activity-hour overlap | WEAK, corroborative-only | Cannot create a link, only strengthen one |
| Timezone conflict ≥8h | negative | |
| Non-overlapping lifespans | negative |
Handle selectivity is the big one. Supply a username frequency corpus and
dave becomes worth almost nothing while kr4ken_x99 becomes worth a great
deal — automatically, without a hand-tuned weight table:
handlecorr score --observations case.csv --corpus top-usernames.txt
Without --corpus every handle looks unique and the CLI warns you. Scores
from a corpus-less run are upper bounds, not assessments.
Mutation handling
>>> normalize("xXkr4ken_1988Xx").root
'kraken'
>>> compare("github:kr4ken", "telegram:kraken_x").relation
'same_root_mutated'
>>> compare("github:admin", "telegram:admin").meaningful
False
Leetspeak, separators, repeated characters, wrapper affixes (xX, _official,
TTV), numeric suffixes, and Cyrillic/Greek homoglyphs. Edit-distance
thresholds scale with root length, because two edits on a six-character root is
coincidence and two edits on a thirteen-character root is a variant.
Generic and short handles are rejected outright rather than scored low — admin
on two platforms is not weak evidence, it is no evidence.
Input format
handle,platform,first_seen,last_seen,source_url,link_email,link_pgp,hours,case_ref
kr4ken,github,2019-03-01,2024-11-02,https://...,,ABCD1234...,"{""14"":22,""15"":31}",CASE-1
kraken_x,telegram,2021-06-14,2026-01-20,https://...,k@example.com,,,CASE-1
Only handle and platform are required. Everything else improves the
assessment; link_* columns are the ones that turn leads into findings.
Output
Feeds directly into attribution-graph for resolution and ICD 203 reporting:
p band grp pair
0.961 ATTRIBUTED 2 handle:github:kr4ken <-> handle:telegram:kraken_x
0.029 WEAK 1 handle:x:kraken1988 <-> handle:npm:krakendev
1 actor cluster(s)
- github:kr4ken, telegram:kraken_x
Not included: breach corpora
Credential dumps are BREACH_CORPUS in the source-class deny list and raise at
collector load in the wider toolkit.
The distinction worth being precise about: correlating handles you observed on a forum — posts, profiles, public member lists you collected in an authorized investigation — is what this package is for. Ingesting a leaked credential dump to look up an email for a handle is a different thing. The second is unlawfully obtained data in most jurisdictions, has unmeasured accuracy, and taints the investigation that touches it. A dump-derived email is the one piece of evidence in your case you cannot put in a declaration.
Dark web sources
adtx_attribution.to_handle_observations() converts a
Robin dark web investigation into
observation rows, carrying the durable identifiers found on the same page as each
handle. That is what can lift two forum accounts above the correlation-point
floor — a shared PGP key on both pages, rather than the handle similarity alone.
Anything Robin's LLM concluded arrives capped at UNCERTAIN and confined to one correlation group. It can corroborate; it cannot establish.
Status
0.1.0, API unstable. Calibration is not validated against labelled
ground truth; bands are principled, not fitted. Stylometry is stubbed
deliberately — the literature does not support the accuracy that commercial
tools claim at short text lengths, and a weak signal presented confidently is
worse than no signal.
License
Apache-2.0.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file handle_correlation-0.6.0.tar.gz.
File metadata
- Download URL: handle_correlation-0.6.0.tar.gz
- Upload date:
- Size: 28.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1aa7650c348872d61243e21ce9a1d0873e265448c9e07c5c3e9b73f2fa5c7296
|
|
| MD5 |
7ec9d85f1dd2cfd8f525a7299b440f4d
|
|
| BLAKE2b-256 |
67868df1ffe66aaf71566edfaaa8498ba4af71345031f07269d47974b0547fea
|
File details
Details for the file handle_correlation-0.6.0-py3-none-any.whl.
File metadata
- Download URL: handle_correlation-0.6.0-py3-none-any.whl
- Upload date:
- Size: 22.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b06d748527f78d9c8a3c65eafbfb399766143c720b6423aa22b4485de4992d53
|
|
| MD5 |
cc63b5f889082c4ff7aa8b9bbcb4f061
|
|
| BLAKE2b-256 |
ce59f3c7ddb41299461d47324718a454cda1ad0d5be73c564c712ead2f92a6bd
|