feat(cross-scan): pivot a finished scan into username and email scans, and rate each hit - #559
Conversation
…ach hit An email scan proves an account exists but rarely learns its name, so it can only reach sites that expose an email check. --cross-scan mines the finished results for usernames and profile links, then scans those usernames across every username module. A swept handle only proves the handle is registered, not who owns it, so every cross-scan hit is rated confirmed / likely / candidate / conflicting against the accounts the target's own verified links confirmed. Adds core/pivots.py (extraction and the host route table), core/confidence.py (scoring) and core/cross_scan.py (orchestration), plus --cross-scan, --cross-links, --cross-depth and --cross-sweep.
Named targets were grouped by the literal username while the swept and checked sets compare case-insensitively, so two profiles linking one account in different cases produced two requests for it and two rows in the export.
A -u / -uf scan is itself a sweep of its own handle, and sites report that handle straight back as a pivot, so its target starts out marked as swept rather than spending a sweep repeating the scan that just finished. An email pass seeds nothing, since its target is not a username.
|
@brunolm Looks clean and better! I will review it once it is finished. However let me know if you have nay questions about the feature. |
The first pass respected the restriction and the cross-scan ignored it, so -m gravatar ran one module then swept 227. Both the sweep and the named checks now stay inside the named modules or categories. Names are re-resolved against user_scan: an email run's -m names email modules, while the sweep needs the username module of the same site.
A username pass has been able to pivot since the feature landed, but the README and the guide still framed --cross-scan as something an email scan does.
|
@kaifcodec it should be ready for review! |
|
@brunolm I will review it shortly, as it is a big addition! |
|
@kaifcodec No, currently it's:
But I think it makes sense to support that, maybe only with emails from platforms where emails are known to be verified. I could try to implement it here or in a follow up PR. Let me know. |
|
Yeah, But when we scan username there is very high chance it will email(s) from it. So implementing this will be the better and most useful option among these, so I think maybe it will be better if you implement that in this PR as well, so you don't have to push the helper files in a new branch again. Anyways in this type of cross scan, as you are already using confidence score I think it will be better if you prioritize the extracted emails from usernames as well before running a scan on extracted emails because in most of the cases we can get multiple emails from a single username (as same username in different sites can be different person) so it will be better to create a list of extracted emails and something like prompt user whether they want to run scan on those all emails or select between them and we will present those emails with confidence score beside them on the terminal based on the frequency and they can choose by interacting with terminal in which email(s) to run cross-scan on and another new flag maybe
|
…xpose
A cross-scan mined usernames and links; the addresses profiles publish went
unused, so sites with no username namespace stayed unreachable from a -u run.
Addresses now become pivots of their own, classified like links by how the
source presented them: an email field the site published against one scraped
out of prose. --cross-emails {all,verified,none} picks the floor, defaulting
tighter than --cross-links because a stray address puts a third party in the
report rather than merely wasting a request.
Each address is rated before it is scanned, with two sites publishing the same
one outranking a single mention, and the accounts it finds inherit that rating.
Loud email modules are skipped rather than prompted for: the address came off
somebody else's profile.
Usernames and addresses share --cross-sweep, half offered to addresses and the
remainder falling to whichever kind can use it.
--cross-scan still described following only usernames and links, and --cross-sweep still called its budget usernames although addresses now draw on the same one.
|
@kaifcodec Implemented in this PR. The table now reads:
And emails are classified this way:
Args:
|
Kick, Flickr, dev.to, Coderwall, Unsplash and 500px publish socials as bare handles rather than URLs, so _pivots_from_links never saw them and the metadata was extracted, exported, then silently dropped. A key naming a platform now yields a pivot from its handle directly, with no URL built: pivots need (site, username), and constructing a URL would have to guess a path shape per platform — YouTube alone needs /c/ for legacy vanity names where @ 404s. discord is excluded: sites store a server invite code there, not an account. The _id suffix is excluded so identifier fields stay out.
|
Okay @brunolm . |
|
@brunolm Most of the features in this PR look solid and work fine! However, I noticed an issue with the Note that If you want to tweak the PDF layout/structure yourself, let me know! Otherwise, I can push the fixes directly using the existing PDF style since I wrote the
|
|
Feel free to push fixes for this. If you need me to change anything I'll be available later. |
- Adds a 'TARGET' column to the main footprint table during cross-scans to clearly distinguish between username and email scan results. - Appends the target identifier next to the platform name in the deep intelligence section. - Resolves the issue where hybrid results from cross-scan passes were rendered purely as username results.
|
@brunolm Pushed the fix. I'll keep this PR open a bit longer while I test a couple more edge cases. Really solid work here though, super impressive PR! |

tl;dr
--cross-scanmines the finished results for usernames, profile links and email addresses, then scans each against the modules for its own kind. On one live authorised address it took attributable accounts from 40 to 65, over 16 sites the email pass could never reach.-urun gains 20–24% more distinct sites — Dropbox, Office365, Amazon, Facebook and 16 more that no handle sweep can reach, because there is nothing handle-shaped to look up.confirmed/likely/candidate/conflictingis what keeps that honest.-ef/-uf/ patterns with two different people."twitter": "BrunoLM7"never matched the URL-based extractor, so 132 handles across the corpus were collected and thrown away. A key named after a platform now becomes a pivot directly.-m/-cnarrow the second pass exactly as they narrow the first.A scan can now reach sites its own mode cannot
--cross-scanruns after the first pass and mines its metadata for three shapes:username: johndoejohndoehttps://github.com/johndoejohndoe, sitegithubemails: johndoe7@gmail.comjohndoe7@gmail.comAll four directions fall out of one mechanism:
-e→ username-u→ username-u→ email-e→ emailLinks resolve to a
(site, username)pair through a route table incore/pivots.py— hosts, path shapes (/in/{user},/users/{id}/{user},/@{user}) and{user}.hostsubdomains. A path that names a site page rather than a person (github.com/settings,youtube.com/channel/UC…) yields nothing.Measured against a plain email scan of the same address:
--cross-scanconfirmed+likely)Both at stock settings — the cross-scan column is
--cross-scanwith no tuning. The email pass is identical in both, and is counted as such: one module was rate-limited during the email-only run and returned an error rather than its registration, so it is counted for both rather than being credited to the cross-scan.Following an address is what unlocks sites with no username namespace
Two live runs at
--cross-emails all --cross-depth 10 --cross-sweep 10, handles anonymised:The address alone out-produced all four handle pivots combined: 21 new sites against 6.
The second run needed depth to get there at all — its own handle has no Gravatar, so the profile carrying the address is itself a round-1 discovery:
At
--cross-depth 1that address never appears.-u johndoe-u johndoe7Half the address's hits duplicate a site a handle already reached — still useful as a second, independent signal, but not new coverage. The other half are only reachable this way, and the same 20 in both runs:
None of these expose a public username namespace to sweep. An email check is the only route to them.
Most raw cross-scan hits are not the target, so every hit is rated
Sweeping a plausible handle returned five different people alongside the real owner. Every hit therefore carries
extra.confidence:confirmedlikelycandidateconflictinglikelyandconflictingare decided against anchors — names, personal domains, e-mail addresses, profile URLs and confirmed accounts harvested from theconfirmedhits. The strongest signal is a link to a confirmed account, matched on the resolved(site, handle)pair rather than URL text, so a renamed host or a different casing still lands:Addresses are rated before they are scanned, on how independently they were reported, and the accounts they find inherit that rating — an account is only as well tied to the target as the address that led to it:
confirmedlikelycandidateconflictingis never used for an address: it carries no name to disagree with, and inferring a mismatch from the local part would mislabel every shared mailbox.Two rating rules had to be removed during development because they were circular —
build_anchorsmines addresses out of the very profiles being rated, so an address on a confirmed profile was vouching for itself.Anchors.link_domainsexists to fix the second one: only domains the target was seen to link to count, not domains inferred from harvested addresses.Where the accounts come from, and how much of the cross-scan's yield survives rating:
--cross-scanconfirmedlikelycandidateconflictingFollowing an address means scanning a third party's mailbox
LOUD_MODULES["user"]is empty;LOUD_MODULES["email"]has 23 entries that mail whoever they are given — password resets, verification links. In a first pass that address is the one the operator typed, so--allow-loudplus a per-module prompt is the right bar. In a cross-scan it came off somebody else's profile, and a second pass should not be making that call.Those modules are therefore dropped from a cross-scan email pivot, not prompted for.
--allow-loudputs them back for a caller who has already accepted the consequence.This is stricter than the existing per-module prompt, and it is a deliberate behavioural difference between the two passes rather than an oversight.
An email field is not proof the address belongs to the account
--cross-emails verifiedkeeps only addresses a site published in its own email field. That says the site published it, not that the site was right about whose it is.PyPI fills
extra["email"]from a package'sauthor_email/maintainer_email, so it can name a co-maintainer or a mailing list:Sampling three real PyPI accounts, two of the three addresses belonged to somebody else:
Keys that name the third party outright (
author_email,maintainer_email) are read astextand excluded by the default, but a module that folds them intoemaildefeats that. Fixing PyPI is a breaking change to that module's output and is deliberately left out of this diff.A multi-target run shares one anchor pool, so it can mis-rate
-ef,-ufand pattern expansion produce several targets in one run, and the cross-scan mines them after the loop as a single pool. Confidence anchors are pooled with them, so one target's identity can vouch for another's handle collision:That is a wrong verdict, not merely a missing one. It is scoped to multi-target runs of different people — a single target is unaffected, and so is a multi-target run of one person's own handles. A live
-ufpass over four distinct identities did not trigger it: the pooled anchors never cross-matched, and every verdict equalled what per-target anchors would have produced.Left as a known limitation rather than fixed here, to keep this diff to the feature. The fix is to key anchors per target instead of per run, which changes
_apply_confidence's signature and is better reviewed on its own.Request volume is the real operational risk
Cross-scan turns one pass into many: each swept username costs roughly a full
-uscan, and each scanned address roughly a full-escan.--cross-sweepcaps that (default 3) across all rounds and both kinds, and0disables sweeping entirely.Half the budget is offered to addresses, rounded down, and whatever one kind cannot use falls to the other:
A budget of 1 still goes to a username, which is what it did before addresses existed.
The failure mode this creates is repeat traffic per site. In a run with six sweeps, one module went from returning a
confirmedhit to erroring on all six handles with a blocked-availability response — so a larger budget can lose you a hit.Raising the budget also buys very little. Pushed to
--cross-sweep 10 --cross-depth 10, the same address yielded 1,515 checks and one additional attributable account over the stock 834 — 82% more traffic for a single hit. Both deep runs above stopped on an exhausted link graph, not an exhausted budget: one used 5 of 10 targets, the other 4.Ranking is by how well-vouched a handle is, not by how plausible it looks, so an opaque platform id arriving through a verified link can outrank a real handle and spend a sweep on a string no other site will ever hold. One such route (
open.spotify.com/user/) is deliberately registered with no path pattern for that reason.A username pass can pivot too
-uand-ufaccept--cross-scan, and the pivot machinery is the same: a username run's profiles advertise the person's other handles, which is exactly what a pivot consumes.The scanned handle itself is seeded as already-swept, and an email pass seeds its own address as already-scanned. A pass is already a sweep of its own target and most sites report it straight back, so without the seed it would rank first and spend the budget repeating the scan that just finished.
Five new flags, all opt-in
--cross-scan--cross-links {all,verified,none}all--cross-emails {all,verified,none}verified--cross-depth N1--cross-sweep N30disables sweeping--cross-emailsdefaults tighter than--cross-linksbecause the cost of being wrong is not symmetric: a stray username pivot wastes a request, a stray address puts a third party into the report.The two share a vocabulary but not a meaning for
none.--cross-links nonestill yields handle pivots, because a handle is not a link;--cross-emails noneyields nothing, because every email class is an address. That also gives address pivoting a real off switch rather than the--cross-sweep 0side effect.-mand-cnarrow the second pass exactly as they narrow the first — the sweep, the named checks, and now the email modules too.-m githubresolves touser_scan/dev/github.pyfor the sweep andemail_scan/dev/github.pyfor an address. Only a restriction naming neither leaves nothing to cross-scan.Links are classed by how far the source platform vouches for them
handle(the site named the account itself),verified(the owner proved control of the far side) andlink(free text the owner typed). The class is inherited, never independently confirmed: it comes from a key namedverified_accounts/verified_links/connected_accounts, or from a(verified)suffix on the URL. Today exactly one module emits either signal, so--cross-links verifiedis narrower than its name suggests.Addresses use the same ladder with two rungs —
field(a site's own email key) andtext(read out of prose) — so--cross-links verifiedand--cross-emails verifiedmean the same thing: nothing the owner merely typed.A bare handle is a link too
Some sites give a linked account as just a name (
"twitter": "BrunoLM7") instead of a link. The extractor only looked for values starting withhttp, so those names were collected and then quietly thrown away.A key named after a platform now turns its handle into a pivot directly.
gaming/kicksocial/flickrcreator/devtogaming/monkeytypedev/github132 of these were being dropped across the stored corpus.
No URL is built, because guessing one is unsafe — Kick stores
BrunoLeonardoMichelsfor YouTube, andyoutube.com/@BrunoLeonardoMichels404s while/c/works. Only the site and handle are needed, so the guess is skipped.Two things are ignored on purpose:
discord(sites put a server invite code there, not a username) and any key ending in_id(those are identifiers, not handles). URL values still work exactly as before.Two things that look like addresses and are not
Replaying 3,158 stored
Foundresults through the extractor caught both, and both would have been scanned:https://www.tiktok.com/@jane.doe//www.tiktok.com/@jane.doe/is permitted in a local partMastodon: @johndoe@mastodon.socialjohndoe@mastodon.socialURLs are now blanked before the address scan (links already arrive as username pivots, so nothing is lost) and a match preceded by a second
@is rejected.Addresses that reach nobody are dropped outright: role mailboxes (
noreply@,postmaster@), RFC 2606 placeholders, reserved TLDs and GitHub's@users.noreply.github.comrelay.hello@andcontact@are kept — that is how a freelancer takes mail.Deliberate omissions
link(so--cross-links verifieddrops it), resolves to no site (so it can only ever feed a sweep, never a named check), and the confidence scorer independently rated its collisionsconflicting.t.co/…yields no pivot and the redirect is not followed, so whatever it points at stays invisible at any depth.Testing, and what was not tested
Live against five identities in both e-mail and username mode, across depth 1/2/10 and sweep 0/1/2/3/4/5/10, all three
--cross-linksvalues, all three--cross-emailsvalues, and a control run with the richest metadata source suppressed to prove pivoting still works from other sites.The address leg specifically: 14 live runs covering both defaults and
all/none, two deep runs at 10/10, an-epass proving the target address is not rescanned, two nonexistent-handle controls (No usernames, links or addresses to pivot from), and three real accounts that yielded no address at all — two with no Gravatar, one with a Gravatar publishing none.The extractor was also replayed over 3,158 stored
Foundresults from 134 previous exports, which is what caught the two false positives above.Also exercised end to end:
-ufbulk, pattern expansion,-mand-cscoping on both halves,--no-nsfw,-t/-Coverrides, and JSON, CSV and PDF export all carryingconfidenceandpivot_source.Not tested:
confirmedaddress occurred live. It needs two sites publishing the same address in an email field; across every corpus and live run, only Gravatar ever published one, so every real address ratedlikely— and so did all 81 accounts they found. Theconfirmedpath is covered only by constructed cases.emailsvalue and the extractor splits them correctly against that format and a synthetic value, but no live account in reach had more than one.biois the largest real prose source in the replayed corpus (11 of 13 text pivots) and the module returnsERRORlive at the moment, so that path is verified only from stored results.-ufpass over four distinct identities produced verdicts identical to per-target anchors.