hermes-agent/optional-skills/security/unbroker/scripts/badbool.py
SHL0MS c2828f2b9b feat(skills): add security/unbroker (autonomous data-broker removal)
unbroker finds where a consenting person's info is exposed across data
brokers and people-search sites and files the removals, running as far as
each site allows and handing only genuinely human-only steps (hard CAPTCHA,
gov-ID, phone, fax) back as an end-of-run digest.

- Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent,
  the broker DB, tier planning, the ledger, email, and the autonomous
  action queue; the agent scans/submits with native tools (web_extract,
  browser_*, delegate_task, cronjob, terminal).
- Verify-before-disclose, least-disclosure (never volunteers SSN), consent
  gate, opaque ids, optional age-at-rest encryption, file-locked ledger.
- Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers
  the state registry (~545) in a single request; BADBOOL + curated
  people-search coverage; scheduled re-scan for re-listing.
- No CAPTCHA-solving services or anti-bot bypass; browser email mode needs
  no stored password.
- 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via
  injected fakes, registry via CSV fixtures). Ships placeholder data only.

Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).
2026-07-02 21:16:10 -04:00

177 lines
6 KiB
Python

"""Pull and parse the Big-Ass Data Broker Opt-Out List (BADBOOL) into broker records.
BADBOOL (https://github.com/yaelwrites/Big-Ass-Data-Broker-Opt-Out-List) is a
maintained, frequently-updated markdown list. `refresh` fetches it and parses the
"People Search Sites" section into records that merge UNDER the curated DB (curated
records always win). Auto-parsed records carry source="BADBOOL-auto" and
confidence="auto" so the agent treats their URLs as best guesses to verify first.
`parse()` is pure (markdown in, records out) so it is tested offline; `fetch()` is
the only network call and can be bypassed by passing markdown directly to refresh().
"""
from __future__ import annotations
import re
import urllib.request
from pathlib import Path
import storage
DEFAULT_URL = (
"https://raw.githubusercontent.com/yaelwrites/"
"Big-Ass-Data-Broker-Opt-Out-List/master/README.md"
)
USER_AGENT = "Mozilla/5.0 (compatible; unbroker/1.0; data opt-out)"
# BADBOOL legend symbols.
SYMBOLS = {
"crucial": "\U0001F490", # 💐
"high": "\u2620", # ☠
"gov_id": "\U0001F3AB", # 🎫
"phone": "\U0001F4DE", # 📞
"payment": "\U0001F4B0", # 💰
}
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_OPTOUT_HINT = re.compile(
r"opt[\- ]?out|optout|removal|remove|suppress|control-privacy|delete", re.I
)
_FIND_HINT = re.compile(r"find|your information|search|look ?up|look for", re.I)
def slug(name: str) -> str:
# Drop a trailing .com/.org/.info on the displayed name so "FastPeopleSearch.com"
# matches the curated id "fastpeoplesearch"; keep .net/.id so distinct sites differ.
n = re.sub(r"\.(com|org|info)\b", "", name.strip(), flags=re.I)
return re.sub(r"[^a-z0-9]+", "", n.lower())
def _heading_flags(heading: str) -> tuple[str, dict]:
flags = {key: (sym in heading) for key, sym in SYMBOLS.items()}
name = heading
for sym in SYMBOLS.values():
name = name.replace(sym, "")
name = name.replace("\ufe0f", "").strip()
return name, flags
def _priority(flags: dict) -> str:
if flags["crucial"]:
return "crucial"
if flags["high"]:
return "high"
return "standard"
def _pick(links: list[tuple[str, str]], hint: re.Pattern) -> str | None:
for _text, url in links:
if hint.search(url):
return url
for text, url in links:
if hint.search(text):
return url
return None
def _clean(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()[:600]
def _build(name: str, flags: dict, body: str) -> dict:
links = _LINK_RE.findall(body)
web = [(t, u) for t, u in links if u.lower().startswith("http")]
mailtos = [u[7:] for _t, u in links if u.lower().startswith("mailto:")]
optout_url = _pick(web, _OPTOUT_HINT)
search_url = _pick(web, _FIND_HINT) or (web[0][1] if web else None)
if flags["phone"]:
method = "phone"
elif optout_url:
method = "web_form"
elif mailtos:
method = "email"
else:
method = "manual"
return {
"id": slug(name),
"name": name,
"category": "people_search",
"priority": _priority(flags),
"jurisdictions": ["US"],
"search": {"method": "url_pattern", "url": search_url, "fetch": "browser",
"match_signal": "result", "by": ["name", "phone", "address"]},
"optout": {
"method": method,
"url": optout_url,
"email": mailtos[0] if mailtos else None,
"requires": {
"gov_id": flags["gov_id"],
"phone_voice": flags["phone"],
"payment": flags["payment"],
"email_verification": False,
"captcha": False,
"account": False,
"phone_callback": False,
},
"inputs": ["full_name", "contact_email"],
"notes": _clean(body),
"links": [{"text": t, "url": u} for t, u in links],
"est_processing_days": 14, # unknown for auto records; drives next_recheck_at
},
"source": "BADBOOL-auto",
"confidence": "auto",
"last_verified": None,
}
def parse(markdown: str) -> list[dict]:
"""Parse the 'People Search Sites' section of BADBOOL into broker records."""
records: list[dict] = []
in_people = False
heading: str | None = None
body: list[str] = []
def flush() -> None:
nonlocal heading, body
if heading is not None:
name, flags = _heading_flags(heading)
if name:
records.append(_build(name, flags, "\n".join(body).strip()))
heading, body = None, []
for line in markdown.splitlines():
if line.startswith("## "):
flush()
in_people = line[3:].strip().lower().startswith("people search")
continue
if not in_people:
continue
if line.startswith("### "):
flush()
heading = line[4:].strip()
elif heading is not None:
body.append(line)
flush()
return records
def fetch(url: str = DEFAULT_URL, timeout: int = 30) -> str:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
return resp.read().decode("utf-8", errors="replace")
MIN_EXPECTED = 20 # BADBOOL's People Search section lists ~47; far fewer => upstream reorg, warn
def refresh(cache_path: Path, url: str = DEFAULT_URL, markdown: str | None = None) -> dict:
"""Fetch (or accept) BADBOOL markdown, parse it, and write the snapshot cache."""
md = markdown if markdown is not None else fetch(url)
records = parse(md)
storage.write_json(cache_path, records)
out = {"parsed": len(records), "cache_path": str(cache_path), "source_url": url}
if len(records) < MIN_EXPECTED:
out["warning"] = (f"only {len(records)} parsed (expected >{MIN_EXPECTED}); BADBOOL's "
"'People Search Sites' section may have moved/reorganized - check the parser")
return out