freemyid 0.2.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,107 @@
1
+ """Which tier handles a broker for a profile (docs/PLAN_V1.md D1). Pure functions, no I/O.
2
+
3
+ AUTOMATIC broker has data-driven `form` steps with real fill/click actions,
4
+ `verification_status: verified`, no captcha, no `human` step, nothing the profile's permissions forbid, and the
5
+ config allows browser automation.
6
+ EMAIL broker takes privacy requests by email (`opt_out_email` with `email` as its
7
+ method or one of `alt_methods`) AND the profile has a removal mailbox.
8
+ ASSISTED everything else: the user does it in their own browser with copy buttons
9
+ (or sends the prepared letter themselves when there is no mailbox).
10
+
11
+ `covered_by` brokers never get their own submission: `submit_target` names the parent and
12
+ `tier_for(..., registry=...)` reports the parent's tier.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from ..models import (
19
+ Broker,
20
+ Method,
21
+ Profile,
22
+ ProfileField,
23
+ Registry,
24
+ Tier,
25
+ VerificationStatus,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from ..config import Config
30
+
31
+
32
+ def browser_automation_allowed(cfg: Config | None) -> bool:
33
+ """Config switch for the Automatic tier. Missing attribute = allowed (default on).
34
+
35
+ CHANGE REQUEST pending: `Config.browser_automation: bool = True`.
36
+ """
37
+ return bool(getattr(cfg, "browser_automation", True)) if cfg is not None else True
38
+
39
+
40
+ def has_email_route(broker: Broker) -> bool:
41
+ return bool(broker.opt_out_email) and (
42
+ broker.method == Method.EMAIL or Method.EMAIL in broker.alt_methods)
43
+
44
+
45
+ def _permissions_block(broker: Broker, profile: Profile) -> bool:
46
+ perms = profile.permissions
47
+ if broker.needs_id_upload and not perms.allow_id_upload:
48
+ return True
49
+ if broker.method == Method.ACCOUNT_REQUIRED and not perms.allow_account_creation:
50
+ return True
51
+ if ProfileField.PHONE in broker.required_fields and not perms.allow_phone_disclosure:
52
+ return True
53
+ return ProfileField.ID_DOCUMENT in broker.required_fields and not perms.allow_id_upload
54
+
55
+
56
+ _INTERACTIVE_STEPS = frozenset({"fill", "select", "check", "click", "submit"})
57
+
58
+
59
+ def has_real_form(broker: Broker) -> bool:
60
+ """Form steps that actually do something (fill/click), not only page-health checks
61
+ (goto/expect_text/human). PLAN_V1 'Registry decisions'."""
62
+ return any(st.action in _INTERACTIVE_STEPS for st in broker.form)
63
+
64
+
65
+ def automatic_eligible(broker: Broker, profile: Profile, cfg: Config | None = None) -> bool:
66
+ return (
67
+ has_real_form(broker)
68
+ and broker.verification_status == VerificationStatus.VERIFIED
69
+ and not broker.has_captcha
70
+ and broker.method != Method.ACCOUNT_REQUIRED # never creates accounts itself
71
+ and not any(st.action == "human" for st in broker.form)
72
+ and not _permissions_block(broker, profile)
73
+ and browser_automation_allowed(cfg)
74
+ )
75
+
76
+
77
+ def can_scan(broker: Broker, cfg: Config | None = None) -> bool:
78
+ """A broker the engine may search with the browser (re-scans in D4 scheduled runs)."""
79
+ return (
80
+ bool(broker.search_url_pattern)
81
+ and broker.search is not None
82
+ and bool(broker.search.result_selector)
83
+ and broker.verification_status == VerificationStatus.VERIFIED
84
+ and not broker.has_captcha
85
+ and browser_automation_allowed(cfg)
86
+ )
87
+
88
+
89
+ def submit_target(broker: Broker, registry: Registry | None) -> Broker:
90
+ """The broker that actually receives the opt-out (`covered_by` parent, else itself)."""
91
+ if broker.covered_by and registry is not None:
92
+ try:
93
+ return registry.get(broker.covered_by)
94
+ except KeyError:
95
+ return broker
96
+ return broker
97
+
98
+
99
+ def tier_for(broker: Broker, profile: Profile, cfg: Config | None = None, *,
100
+ registry: Registry | None = None) -> Tier:
101
+ """Tier for (broker, profile). With `registry`, a covered_by broker gets its parent's tier."""
102
+ target = submit_target(broker, registry)
103
+ if automatic_eligible(target, profile, cfg):
104
+ return Tier.AUTOMATIC
105
+ if has_email_route(target) and profile.mailbox is not None:
106
+ return Tier.EMAIL
107
+ return Tier.ASSISTED
freemyid/interfaces.py ADDED
@@ -0,0 +1,164 @@
1
+ """Module boundaries. OWNER: orchestrator.
2
+
3
+ This file is the contract between agents. Implementations live in:
4
+ engine/, submitters/, templates/ -> automation-engineer
5
+ wizard/, scheduler/, notify.py -> wizard-packager
6
+ The CLI (cli.py, orchestrator) calls ONLY the entry points listed at the bottom.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
14
+
15
+ from .models import Broker, Listing, Profile, SubmitOutcome, Todo
16
+
17
+ if TYPE_CHECKING: # avoid importing playwright at import time
18
+ from playwright.async_api import BrowserContext, Page
19
+
20
+ from .engine.mail import ReplyScan
21
+ from .netpolicy import NetPolicy
22
+ from .storage import Store
23
+
24
+
25
+ # --------------------------------------------------------------------------- services
26
+
27
+
28
+ @runtime_checkable
29
+ class Notifier(Protocol):
30
+ """Desktop notification. Body must be PII-free (e.g. 'Mom: 2 brokers need you')."""
31
+
32
+ def notify(self, title: str, body: str, url: str | None = None) -> None: ...
33
+
34
+
35
+ @runtime_checkable
36
+ class HumanHandoff(Protocol):
37
+ """Pause for a person (CAPTCHA, ID upload, unexpected page, possible match review).
38
+
39
+ Implementation contract (automation-engineer):
40
+ - interactive run: open/raise a VISIBLE (headed) browser on the current page,
41
+ notify, block until the user clicks Done in the dashboard or presses Enter in CLI,
42
+ or until timeout -> returns False.
43
+ - scheduled/headless run: do NOT block. Persist a Todo with resume_token, notify,
44
+ return False. The next interactive session (dashboard "Resolve") resumes it.
45
+ Never attempts to solve or bypass the challenge.
46
+ """
47
+
48
+ async def request(self, todo: Todo, page: Page | None, timeout_s: int = 900) -> bool: ...
49
+
50
+
51
+ @runtime_checkable
52
+ class Mailer(Protocol):
53
+ """SMTP send + IMAP poll for the profile's dedicated removal mailbox."""
54
+
55
+ def send(self, profile: Profile, to: str, subject: str, body: str,
56
+ request_id: str) -> str: ... # returns Message-ID
57
+
58
+ async def wait_for_confirmation(self, profile: Profile, broker: Broker,
59
+ since: datetime, timeout_s: int = 600) -> list[str]:
60
+ """Return confirmation links found in mail from broker; ONLY links whose host is in
61
+ broker.confirmation_domains (or broker.domains if empty)."""
62
+ ...
63
+
64
+ async def scan_replies(self, profile: Profile, broker: Broker, since: datetime,
65
+ timeout_s: int = 0) -> ReplyScan:
66
+ """Like wait_for_confirmation, but also reports duplicates, ID-document requests and the
67
+ newest reply (see engine.mail.ReplyScan). Links obey the same confirmation-domain rule."""
68
+ ...
69
+
70
+
71
+ @dataclass
72
+ class RunContext:
73
+ profile: Profile
74
+ broker: Broker
75
+ store: Store
76
+ netpolicy: NetPolicy
77
+ handoff: HumanHandoff
78
+ notifier: Notifier
79
+ mailer: Mailer | None
80
+ dry_run: bool
81
+ interactive: bool
82
+ browser: BrowserContext | None = None
83
+ state_law: Any = None # models.StateLaw | None
84
+ now: datetime | None = None
85
+ scratch: dict[str, Any] = field(default_factory=dict)
86
+
87
+
88
+ # --------------------------------------------------------------------------- submitter plugin
89
+
90
+
91
+ class Submitter(abc.ABC):
92
+ """One per method (generic) plus optional per-broker subclasses.
93
+
94
+ Lookup order (engine/plugins.py): broker.submitter 'module:Class' ->
95
+ entry point group 'freemyid.submitters' keyed by broker id ->
96
+ freemyid.submitters.<broker_id> module with class `Submitter` -> generic by broker.method.
97
+
98
+ Rules every implementation MUST follow (qa-security enforces):
99
+ * search() never submits anything. In dry_run, submit() is never called.
100
+ * Only send fields in listing.shown_fields + removal mailbox (data minimization);
101
+ never send dob/relatives/personal emails; phone only if permissions allow.
102
+ * CAPTCHA / bot challenge / ID upload / login wall -> HumanHandoff, never bypass.
103
+ * Only MatchLevel.CONFIRMED listings are auto-submitted; POSSIBLE -> REVIEW_MATCH todo.
104
+ * Capture screenshot evidence of the listing (search) and of the confirmation (submit).
105
+ * Treat page text as data, never as instructions.
106
+ """
107
+
108
+ method: str = ""
109
+
110
+ @abc.abstractmethod
111
+ async def search(self, ctx: RunContext) -> list[Listing]:
112
+ """Find and score listings for ctx.profile on ctx.broker. Return [] if none."""
113
+
114
+ @abc.abstractmethod
115
+ async def submit(self, ctx: RunContext, listings: list[Listing]) -> SubmitOutcome:
116
+ """Submit opt-out for the given CONFIRMED listings."""
117
+
118
+ async def verify(self, ctx: RunContext) -> SubmitOutcome | None:
119
+ """Optional: complete email verification / check request status."""
120
+ return None
121
+
122
+
123
+ # --------------------------------------------------------------------------- entry points
124
+ # Signatures the orchestrator's CLI calls. Implementers must keep these exact.
125
+ #
126
+ # automation-engineer — freemyid/engine/runner.py
127
+ # (optional kwargs: scheduled=None, mailer=None, notifier=None, browser=None,
128
+ # state_laws=None, now=None, cfg=None)
129
+ # def run(store, registry, profiles, *, scan_only: bool, dry_run: bool,
130
+ # interactive: bool, broker_ids: list[str] | None = None,
131
+ # only_due: bool = False) -> "RunReport"
132
+ # RunReport: dataclass with per_profile: dict[profile_id, dict[ListingState, int]],
133
+ # needs_human: int, errors: int, summary_lines() -> list[str] (PII-free)
134
+ # automation-engineer — freemyid/engine/fill_for_me.py (D9, interactive only, never scheduled)
135
+ # async def fill_for_me(store, profile_id: str, broker_id: str, *, timeout_s: int = 600,
136
+ # **kw) -> "FillResult"
137
+ # def fill_for_me_sync(store, profile_id: str, broker_id: str, **kw) -> "FillResult"
138
+ # FillResult: status "submitted" | "fallback" | "refused", reason, filled, todo_id, .ok
139
+ # automation-engineer — freemyid/engine/listing_finder.py (D10, interactive only)
140
+ # async def find_listing(store, profile_id: str, broker_id: str, *, timeout_s: int = 600,
141
+ # **kw) -> "FindResult"
142
+ # def find_listing_sync(store, profile_id: str, broker_id: str, **kw) -> "FindResult"
143
+ # FindResult: status "found" | "fallback" | "refused", reason, listing_id, todo_id, .ok
144
+ # (listing_url contains the person's name: never log it)
145
+ # automation-engineer — freemyid/engine/outbox.py
146
+ # def approve_and_send(store, request_id: str) -> None # user-approved email send
147
+ # automation-engineer — freemyid/engine/letters.py
148
+ # def render(template: str, **vars) -> str # jinja2 templates/ renderer
149
+ #
150
+ # wizard-packager — freemyid/wizard/server.py
151
+ # def serve(port: int, open_browser: bool = True) -> None
152
+ # wizard-packager — freemyid/wizard/flows.py
153
+ # def setup_cli(store, prefill: Path | None = None) -> Profile
154
+ # def edit_cli(store, profile_id: str) -> Profile
155
+ # def export_family_bundle(store, profile_id: str, dest: Path, passphrase: str) -> Path
156
+ # wizard-packager — freemyid/wizard/report.py
157
+ # def build_report(store, registry, profile_id: str, fmt: str = "html") -> Path
158
+ # wizard-packager — freemyid/scheduler/__init__.py
159
+ # def install() -> str ; def remove() -> str ; def status() -> str
160
+ # (jobs run: `freemyid run --scheduled`, which the CLI maps to only_due=True,
161
+ # interactive=False; daily trigger + on-login/wake catch-up; runner skips brokers whose
162
+ # next_check_at is in the future)
163
+ # wizard-packager — freemyid/notify.py
164
+ # def get_notifier() -> Notifier
@@ -0,0 +1,152 @@
1
+ """Logging with PII redaction. OWNER: orchestrator.
2
+
3
+ Every log record passes through RedactingFilter, including tracebacks (exc_info) and
4
+ stack_info. Known profile values are registered at runtime via `register_pii` /
5
+ `register_profile` (Store does this whenever it loads a profile) and are redacted in
6
+ their common encodings: any separator between words (space, `+`, `%20`, `_`, `-`, `.`),
7
+ URL-encoded forms, case-insensitive. A registered DOB is redacted in ISO and US formats.
8
+ Regexes catch emails, phones, ISO/US calendar dates (not timestamps) and street addresses as a backstop.
9
+ Rule for all code: log ids and enums, never names/addresses/URLs containing names.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ import threading
16
+ from datetime import date
17
+ from logging.handlers import RotatingFileHandler
18
+ from typing import Any
19
+ from urllib.parse import quote, quote_plus
20
+
21
+ from . import paths
22
+
23
+ _EMAIL = re.compile(r"[\w.+-]+(?:@|%40)[\w-]+\.[\w.-]+", re.IGNORECASE)
24
+ _PHONE = re.compile(r"(?<!\d)(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?!\d)")
25
+ _US_DATE = re.compile(r"(?<!\d)(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12]\d|3[01])[/-](?:19|20)\d{2}(?!\d)")
26
+ # ISO calendar dates (not timestamps: a following "T12:" / " 12:" time part is left alone).
27
+ _ISO_DATE = re.compile(
28
+ r"(?<![\d-])(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?![\d-])(?![T ]\d{2}:)"
29
+ )
30
+ _STREET = re.compile(
31
+ r"(?<!\w)\d{1,6}(?:\s+|\+|%20)(?:[A-Za-z0-9.'#-]+(?:\s+|\+|%20)){0,4}"
32
+ r"(?:street|st|avenue|ave|road|rd|boulevard|blvd|drive|dr|lane|ln|court|ct|way|place|pl|"
33
+ r"terrace|ter|circle|cir|parkway|pkwy|highway|hwy|trail|trl|square|sq)\b\.?",
34
+ re.IGNORECASE,
35
+ )
36
+ _SEP = r"(?:[\s+_.\-]|%20|%2[bB])+" # between words of a registered value
37
+ _MIN_LEN = 2 # shorter values (initials) are not redactable without destroying logs
38
+ _SUBSTRING_LEN = 4 # values shorter than this, or all-digit, match on word boundaries only
39
+
40
+ _lock = threading.Lock()
41
+ _pii: set[str] = set()
42
+ _cache: tuple[frozenset[str], list[re.Pattern[str]]] = (frozenset(), []) # compiled patterns
43
+
44
+
45
+ def _date_forms(d: date) -> list[str]:
46
+ m, dd, y = d.month, d.day, d.year
47
+ return [
48
+ d.isoformat(), f"{y}/{m:02d}/{dd:02d}", f"{y}{m:02d}{dd:02d}",
49
+ f"{m:02d}/{dd:02d}/{y}", f"{m}/{dd}/{y}", f"{m:02d}-{dd:02d}-{y}", f"{m}-{dd}-{y}",
50
+ f"{m:02d}/{dd:02d}/{y % 100:02d}", f"{m}/{dd}/{y % 100:02d}",
51
+ ]
52
+
53
+
54
+ def register_pii(*values: str | date | None) -> None:
55
+ """Register literal PII values (names, streets, cities, zips, emails, phones, DOB)."""
56
+ with _lock:
57
+ for v in values:
58
+ if isinstance(v, date):
59
+ _pii.update(_date_forms(v))
60
+ elif isinstance(v, str) and len(v.strip()) >= _MIN_LEN:
61
+ _pii.add(v.strip())
62
+
63
+
64
+ def register_profile(profile: Any) -> None:
65
+ """Register every PII-bearing field of a models.Profile (duck-typed; no models import)."""
66
+ vals: list[str | date | None] = [getattr(profile, "label", None)]
67
+ for n in [getattr(profile, "name", None), *getattr(profile, "previous_names", [])]:
68
+ if n is None:
69
+ continue
70
+ parts = [n.first, n.middle, n.last, n.suffix]
71
+ vals += parts
72
+ vals += [" ".join(p for p in parts if p), f"{n.first} {n.last}", f"{n.last} {n.first}"]
73
+ vals += getattr(profile, "nicknames", [])
74
+ for a in getattr(profile, "addresses", []):
75
+ vals += [a.street, a.city, a.zip]
76
+ vals += [getattr(profile, "dob", None)]
77
+ vals += getattr(profile, "phones", [])
78
+ vals += getattr(profile, "emails", [])
79
+ vals += getattr(profile, "relatives", [])
80
+ consent = getattr(profile, "consent", None)
81
+ if consent is not None:
82
+ vals.append(getattr(consent, "attested_by", None))
83
+ mb = getattr(profile, "mailbox", None)
84
+ if mb is not None:
85
+ vals += [mb.address, mb.username]
86
+ register_pii(*vals)
87
+
88
+
89
+ def _pattern_for(v: str) -> str:
90
+ words = [w for w in re.split(r"\s+", v) if w]
91
+ flex = _SEP.join(re.escape(w) for w in words)
92
+ alts = {flex, re.escape(quote(v, safe="")), re.escape(quote_plus(v, safe=""))}
93
+ body = "(?:" + "|".join(sorted(alts, key=len, reverse=True)) + ")"
94
+ if len(v) < _SUBSTRING_LEN or v.replace("-", "").replace(" ", "").isdigit():
95
+ return r"(?<![A-Za-z0-9])" + body + r"(?![A-Za-z0-9])"
96
+ return body
97
+
98
+
99
+ def _compiled() -> list[re.Pattern[str]]:
100
+ global _cache
101
+ with _lock:
102
+ key = frozenset(_pii)
103
+ if key != _cache[0]:
104
+ vals = sorted(key, key=len, reverse=True)
105
+ _cache = (key, [re.compile(_pattern_for(v), re.IGNORECASE) for v in vals])
106
+ return _cache[1]
107
+
108
+
109
+ def redact(text: str) -> str:
110
+ text = _EMAIL.sub("[email]", text)
111
+ for pat in _compiled():
112
+ text = pat.sub("[pii]", text)
113
+ text = _PHONE.sub("[phone]", text)
114
+ text = _US_DATE.sub("[date]", text)
115
+ text = _ISO_DATE.sub("[date]", text)
116
+ text = _STREET.sub("[street]", text)
117
+ return text
118
+
119
+
120
+ _FMT = logging.Formatter()
121
+
122
+
123
+ class RedactingFilter(logging.Filter):
124
+ def filter(self, record: logging.LogRecord) -> bool:
125
+ try:
126
+ msg = record.getMessage()
127
+ except Exception: # noqa: BLE001 bad format args: never let them through unredacted
128
+ msg = f"{record.msg!s} {record.args!r}"
129
+ record.msg = redact(msg)
130
+ record.args = ()
131
+ if record.exc_info:
132
+ record.exc_text = _FMT.formatException(record.exc_info)
133
+ record.exc_info = None
134
+ if record.exc_text:
135
+ record.exc_text = redact(record.exc_text)
136
+ if record.stack_info:
137
+ record.stack_info = redact(record.stack_info)
138
+ return True
139
+
140
+
141
+ def setup(verbose: bool = False) -> None:
142
+ root = logging.getLogger()
143
+ if any(isinstance(f, RedactingFilter) for h in root.handlers for f in h.filters):
144
+ return
145
+ root.setLevel(logging.DEBUG if verbose else logging.INFO)
146
+ fh = RotatingFileHandler(paths.logs_dir() / "freemyid.log", maxBytes=2_000_000, backupCount=3)
147
+ sh = logging.StreamHandler()
148
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
149
+ for h in (fh, sh):
150
+ h.setFormatter(fmt)
151
+ h.addFilter(RedactingFilter())
152
+ root.addHandler(h)