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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
freemyid/engine/tiers.py
ADDED
|
@@ -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)
|