Skip to content

feat(brokers): verified registry-sourced list, custom list config, audit auto-prune - #55

Merged
JustMaris merged 13 commits into
mainfrom
feat/verified-broker-list
Sep 12, 2026
Merged

feat(brokers): verified registry-sourced list, custom list config, audit auto-prune#55
JustMaris merged 13 commits into
mainfrom
feat/verified-broker-list

Conversation

@JustMaris

Copy link
Copy Markdown
Member

Closes #54.

The full broker list is broad and inherited from upstream, so it carries noise — dead domains, non-brokers, stale contacts. This addresses that in three parts rather than rewriting 762 entries.

1. Verified list (data/brokers-verified.yaml)

A second embedded list (48 entries): companies we can confidently call data brokers — US names cross-checkable against state registries, EU credit / data-intelligence bureaus — each with a working removal contact. The people-search / background-check set plus the big registry-listed marketing/adtech brokers (Acxiom, Epsilon, Oracle, LiveRamp, Experian/Equifax/TransUnion marketing arms, Kochava, Gravy Analytics, SafeGraph, …) and EU bureaus (Creditreform, Regis24, Creditsafe).

  • eraser send --list verified (per run) or options.broker_list: verified (permanent)
  • Full list stays the default
  • New broker.LoadList resolution for the send-family commands (send, draft, mark-sent, serve): --brokers flag → options.broker_file → verified → ~/.eraser/brokers.yaml → embedded full. Everything else (audit-brokers, guides, reply processing) stays on the full list.

Grow it from registry CSVs with the existing scripts/import-registries — see docs/auditing.md.

2. options.broker_file

Config equivalent of the global --brokers flag, for web / non-CLI users. --brokers still wins.

3. audit-brokers --fix + weekly auto-prune

--fix clears the email of every broker whose mail domain is dead (MX and host lookup both fail — the reliable signal; website verdicts are never acted on), keeping the row and appending a dated note. .github/workflows/broker-audit.yml now runs --fix weekly and opens a PR when data/brokers.yaml changed, instead of failing red for a maintainer to prune by hand. A quiet week opens no PR.

Not in scope

One-by-one review of individual questionable entries in the full list (the "law firms" point from #54) stays manual via docs/broker-replies.md. The verified list is the structural answer for users who want signal now.

Notes

  • broker.Validate gained a minCount param — verified list floor is MinVerifiedBrokerCount (20), full list keeps MinSaneBrokerCount (200). validate-brokers with no arg now checks both embedded lists.
  • Tests: broker.LoadList precedence, verified embed parses + validates, applyAuditFix only touches email-dead entries.
  • go build / go vet / go test -race / golangci-lint / staticcheck / deadcode all clean.

Manual verification

  • send --list verified --dry-run → 48 brokers; full → 590
  • options.broker_file → only that list; --brokers flag still overrides
  • audit-brokers --fix on a fixture → cleared only the email-dead entry, email: "" + dated note, row otherwise intact, .bak written, exit 0, still passes validate-brokers

…dit auto-prune

Addresses #54 (outdated / unverified broker email addresses) in three parts:

- data/brokers-verified.yaml: a smaller second embedded list (48 entries) of
  companies confirmed to be data brokers (US: state-registry-checkable; EU:
  credit / data-intelligence bureaus) with a working removal contact. Selected
  with `send --list verified` or options.broker_list: verified. The full list
  stays the default. broker.LoadList resolves: --brokers flag -> options.broker_file
  -> verified -> ~/.eraser/brokers.yaml -> embedded full.

- options.broker_file: point the send-family commands at your own list from
  config (the CLI already had --brokers).

- audit-brokers --fix: clears the email of every broker with a dead mail domain
  (MX + host lookup both fail), keeping the row and adding a dated note. The
  weekly broker-audit workflow now runs --fix and opens a PR when the list
  changed, instead of just failing red for a maintainer to prune by hand.

Out of scope: one-by-one review of individual questionable entries in the full
list - that stays manual via docs/broker-replies.md.
…ied list

Ran a per-broker web-search sweep over all 762 entries in data/brokers.yaml
(legitimacy, operating status, contact currency). Applied only low-risk fixes
directly; everything else is a flagged note for manual review, nothing deleted:

- 19 entries: on-file contact was a stale/personal address or missing an
  opt-out URL - updated to the company's own published privacy/DSR contact.
- 46 entries: flagged in notes as possibly not a data broker (21), looks
  defunct (3), a possible duplicate of another entry (2), or uncertain (20) -
  each with the evidence found, for a human to confirm before any deletion.

data/brokers-verified.yaml: removed datajoy-eu (self-admits "not even a data
broker", corroborating an existing note) and oracle-america (its ad/data
business - BlueKai/Oracle Data Cloud - shut down in 2024); added ussearch's
missing opt-out URL. EU coverage in the verified list is down to 4 entries
and still needs the registry-sourced expansion this was meant to get.

data/brokers.yaml: also fixed background-information-services by hand from a
forwarded broker reply - FCRA-exempt consumer reporting agency, no record on
file, miscategorized as marketing instead of background-check.

Full findings: see the sweep summary shared alongside this commit.
…unt floor

Per follow-up review of the sweep's uncertain/duplicate findings:

- Merge brightcheck into brigthswipe (same company - PRNewswire confirms
  BrightSwipe Inc. as the entity behind the BrightCheck product).
- Clean up the zenleads note - its contact already routes to Apollo.io
  directly, no separate Apollo.io entry exists to merge into, so it's kept
  rather than flagged as a possible duplicate.
- Remove the 20 entries the sweep could only mark "uncertain": no
  corroborating web presence at all, or a real company whose own privacy
  policy states it doesn't sell personal data (SaaS/consulting vendors
  processing a client's own data, not brokering third-party consumer data).

List count: 762 -> 741. Update the "750+" claim to "700+" in README.md,
docs/architecture.md, docs/broker-replies.md, and EU-NOTES.md - the count
floor's own note had flagged that a dedup pass below 750 would make it false.
Resolved the sweep's remaining not_a_broker/looks_defunct flags:

- Deleted 20 not_a_broker entries: SaaS/consulting/insurance vendors whose own
  privacy policies explicitly disclaim selling personal data (HubSpot, Ipsos,
  Greenhouse, Qwoted, Accenture, Deloitte Consulting, PwC Product Sales, UPS
  Capital, and others) - they process a client's own data rather than
  brokering third-party consumer data, so an erasure request has nothing to
  act on regardless of address.
- Kept birchwood-credit-services instead of deleting it: it's an FCRA-covered
  consumer reporting agency, same exemption pattern already established for
  goodhire/saferent-solutions/background-information-services in this list -
  email cleared, category corrected marketing -> background-check, note cites
  the exemption.
- Deleted the 3 looks_defunct entries: near-north-america (Near Intelligence
  filed bankruptcy 2023, already-bounced email), oracle-america (its ad-data
  business, BlueKai/Oracle Data Cloud, shut down in 2024 - matches the earlier
  removal from the verified list), veromi (already-bounced email, domain now
  parked).

List count: 741 -> 718 (still comfortably above the "700+" floor).
EU-NOTES.md's EU/ad-tech entry list updated to drop datajoy-eu and scope3.
Added 10 real EU data brokers/data-intelligence companies, each verified via
their own current GDPR/privacy-request contact (not imported blind):

Credit/B2B-intelligence bureaus (financial-b2b):
- schufa (SCHUFA, Germany - major consumer credit bureau)
- crif (CRIF, Italy - pan-EU credit/business-information bureau)
- dun-bradstreet-eu (D&B EU, incl. former Bisnode entities)

Address/marketing data brokers:
- deutsche-post-direkt (Germany)
- az-direct (Germany/Austria, Bertelsmann group - note cites a 2026 noyb
  complaint about its responsiveness)

Ad-tech (marketing):
- adform (Denmark), criteo (France), rtb-house (Poland),
  equativ/Smart AdServer (France), teads (France)

data/brokers-verified.yaml: added these 10 plus two already-present EU entries
with solid contacts (smartclip, etarget-sk) that hadn't been copied over yet -
verified EU count 4 -> 16 (out of 19 EU-region entries in the full list;
seawave-media has no email, adikteev is a processor-not-controller dead end,
genius-sports is region:global so already covered regardless of filter).

List counts: full 718 -> 728, verified 46 -> 58. EU-NOTES.md's EU entry list
and the count narrative updated accordingly.
Manual validation pass over the full list (723 entries) and verified list
(61 entries):

- Removed stale duplicates: infogroup, towerdata (superseded by atdata),
  gravy-analytics (superseded by unacast), tapad, zabasearch (dup id),
  us, freshaddress, seawave-media, whitepages-2, intelius-2,
  epsilon-data-management, free-data-services, environics-analytics.
- Fixed trailing-dot-broken opt_out_url values: monevo, skyhook-holding,
  truth-now.
- Refreshed privacy/removal contacts from current site content: equativ,
  foursquare, peoplesmart, intelius, instantcheckmate, truthfinder,
  equifax-marketing.
- Added region: global consumer-reporting/credit bureaus not previously
  covered: nosis (Argentina), circulo-de-credito and buro-de-credito-mx
  (Mexico), datacredito-experian-co (Colombia), cial-dun-bradstreet
  (LatAm), centrix-nz (New Zealand), metropol-crb (Kenya).
- category: requires-id on adform and nosis (both require ID verification
  before acting on a request).

Docs updated to match: EU-NOTES.md, README.md, docs/architecture.md,
docs/auditing.md, docs/commands.md.
broker.Select(ids, regions, categories, excluded, excludedCategories) generalizes
Filter into arbitrary broker selection; Filter now delegates to it. UnknownIDs
reports any --broker/broker_ids values that don't match a real entry, so a typo
fails loudly instead of silently sending to nobody.

CLI: `send` gains --broker, --region, --category, --exclude (repeatable/CSV) and
--status (eligible|never|failed|all, replacing the old resend-cooldown-only
skip logic) via the new filterBrokersByStatus. --resend now means --status all.

Web: getBrokersWithStatus takes includeIDs/excludeIDs and now also honors
options.regions (previously only enforced by the CLI's Filter path - a
configured region restriction had no effect on the web brokers list or bulk
send). handleAPISendAll exposes broker_ids/exclude_ids form fields and an
"eligible" status option; both handleAPISendOne and handleAPISendAll now
refuse to send while options.dry_run is set (previously only the CLI path
respected dry_run). brokers.html gets an advanced-filters panel wired to the
new fields.

Tests: cmd/eraser/cmd_send_test.go, internal/web/handlers_send_filters_test.go.
… rolling daily limit

Three related bugs found in a code review of the send-job path, all in the
same area: JobManager/JobPersistence and processSendJob.

1. handleAPISendAll's GetActive-then-Create had a TOCTOU window: two
   concurrent requests for the same profile could both pass the "no active
   job" check before either created one, each spawning its own
   processSendJob and double-sending every broker in the batch.
   JobManager.CreateIfNoActive checks and inserts under one lock so only one
   request can win; the early GetActive check stays as a fast-fail (skip
   building the send list and validating the sender for a request that's
   going to be rejected anyway), it's just no longer the only guard.

2. JobPersistence wrote every profile's pending-job state to the same
   pending_job.json. GetActive already lets two profiles send concurrently
   ("each against its own daily limit and history" per its own doc
   comment), so a second profile's Save silently overwrote the first's, and
   a restart could only ever resume (or forget) whichever wrote last.
   Persistence is now keyed by profile (pending_job-<id>.json; the default
   profile keeps the legacy bare filename so an in-flight job survives this
   upgrade), and checkPendingJob resumes every configured profile's pending
   job, not just one.

3. processSendJob's daily-limit check compared against a local `sent`
   counter that always starts at 0 - so daily_send_limit only ever capped a
   single invocation. Resuming a paused job after a restart, or clicking
   "Send all" again later the same day, could each push another full batch
   past the configured limit. It's now anchored to CountSentSince's actual
   rolling-24h total, the same source the CLI's `send` already uses.

Tests: TestCreateIfNoActiveRejectsSecondJobForSameProfile,
TestCreateIfNoActiveIsRaceSafe (run with -race),
TestJobPersistencePerProfileFiles,
TestJobPersistenceDefaultProfileUsesLegacyFilename,
TestProcessSendJobRespectsRollingDailyLimit.
SMTPSender.Send takes a context.Context - both handleAPISendOne and
processSendJob wrap it in a 30s timeout, and a job's Cancel button cancels
it - but the actual network code (tls.Dial, smtp.NewClient, Auth/Mail/Rcpt/
Data/Write/Close/Quit) never looked at ctx at all. net/smtp has no context
support of its own, so a broker's SMTP server that accepted the TCP
connection and then never responded (a hang, a firewall black-holing the
session) blocked the send indefinitely: the 30s timeout was a no-op, and
clicking Cancel on a stuck job did nothing - the goroutine just kept
blocking on the read.

send() now dials with DialContext, sets a deadline from ctx when one is
present, and runs a watcher goroutine that closes the connection on
ctx.Done() - net/smtp's blocking Read/Write calls return an error as soon
as the underlying conn closes, which is what actually makes both the
timeout and the cancel button work. TLS handshakes use HandshakeContext
directly rather than tls.Dial. Also collapsed sendWithTLS and the
smtp.SendMail plaintext path into one send() that both callers share,
rather than plaintext going through a second, unfixed code path.

Tests: TestSendRespectsContextDeadline, TestSendRespectsContextCancellation
(both against a listener that accepts a connection and never responds).
FetchBounceEmails classifies a message as a bounce/NDR purely from its
From/Subject text - neither is authenticated, and every broker's email is
public in this open-source repo's data/brokers.yaml. Anyone who can land a
message in the monitored inbox (a spoofed From, or any sender not
SPF/DKIM-checked) could shape a fake NDR naming any of the ~700 public
broker addresses and have `cleanup-bounces --remove` silently clear that
broker's real contact.

cleanup-bounces now only acts on a "bounce" whose extracted address belongs
to a broker at least one configured profile has an actual successful-send
history record for. This doesn't authenticate the bounce itself, but it
shrinks the attack surface from any public broker address down to ones this
user has genuinely emailed - a much narrower, much less guessable set.
Unmatched candidates are printed as skipped rather than silently dropped, so
a real bounce that lands here (e.g. from a broker emailed via a since-removed
profile) is still visible for the user to act on by hand.

everSentBrokerIDs factors the set-building out for testing (real IMAP
scanning isn't mocked in this test suite, so it's the part that can
actually be unit tested).

Tests: TestEverSentBrokerIDs, TestEverSentBrokerIDsSingleProfileConfig.
The server binds 127.0.0.1 only, and csrf.Protect enforces same-origin via
the Sec-Fetch-Site header rather than a token - but Sec-Fetch-Site doesn't
cover DNS rebinding. A browser computes it from the requesting page's
origin STRING (scheme+host+port), not the IP that hostname currently
resolves to, so a page served from an attacker-controlled hostname that's
been DNS-rebound to 127.0.0.1 still reads as same-origin to the browser -
Sec-Fetch-Site: same-origin, cookies included - even though the request is
actually reaching this local server. The only place that mismatch is still
visible is the Host header, which still names the attacker's hostname.

requireLoopbackHost rejects any request whose Host isn't 127.0.0.1, ::1, or
localhost, registered before securityHeaders/csrf.Protect in the middleware
chain. Since nothing outside the machine can reach this port under any
hostname at all, a non-loopback Host only ever shows up via this rebinding
trick, never a legitimate request.

Existing router-level tests (routes_smoke_test.go) built requests with
httptest.NewRequest, which defaults Host to "example.com" - now rejected by
design, so they're updated to a small loopbackRequest helper that sets a
realistic Host, matching what a real browser actually sends.

Tests: TestHostAllowed, TestRequireLoopbackHostBlocksNonLoopback.
The verified list's original 46 US non-EU entries all carried the same
generic note - "cross-checkable against the California CPPA data-broker
registry" - asserted when the list was first built, never actually checked
per-entry. 24 of those notes survived the later sweep/dedup passes; this
runs the check that was owed.

Findings (oag.ca.gov's historical registry, since cppa.ca.gov itself wasn't
reachable to query directly):

- 16 confirmed: alliant-cooperative-data-solutions, beenverified,
  cuebiq-group, foursquare-labs, instantcheckmate, kbm-group (registration
  independently corroborates its existing note), kochava, merkle, mylife,
  neighborwho (covered under BeenVerified's registration), nuwber,
  peoplefindersdaas, safegraph, spokeo, truthfinder, versium-analytics.
  Notes now cite the specific oag.ca.gov registration.
- 7 not found under this name in a targeted search: advancedbackgroundchecks,
  checkr (plausibly FCRA-exempt, which would explain rather than concern),
  corelogic (likely registered under a subsidiary name), fastpeoplesearch,
  peoplesmart, thatsthem, usphonebook. Kept in the list - the verified bar is
  a confirmed broker with a current working contact, which none of this
  changes - but the notes now say plainly that registry provenance wasn't
  confirmed, instead of asserting it.
- idtrue: turned up defunct mid-check (HireRight shut it down in March 2025
  after acquiring backgroundchecks.com; www.idtrue.com no longer resolves).
  Removed from both lists rather than just noted, matching how
  near-north-america/oracle-america/veromi were already handled this
  session.

All edits preserve each entry's pre-existing notes (a prior full-replace
attempt at this lost real content - e.g. checkr's bounce history, Merkle's
dentsu-merge note - and was reverted before landing).
@JustMaris
JustMaris merged commit 460a8b8 into main Sep 12, 2026
3 checks passed
@JustMaris
JustMaris deleted the feat/verified-broker-list branch September 12, 2026 13:48
@dnb-robot dnb-robot Bot mentioned this pull request Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Outdated E-Mail Addresses.

1 participant