Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@ unsupported language, or an inconsistent configuration raises
`PrivacyModelUnavailable` before analysis starts, rather than quietly scrubbing
worse than you expected.

## Git / CI scan (`[scan]`)

A regex gate for git trees. Clinic CI calls it so an OHIP-shaped number, a
chart id, an MRN, a UNC path, or an RDP hostname can't land in a commit. It
doesn't scrub recordings. It does not claim clinical validation.

```bash
pip install "openadapt-privacy[scan]"
openadapt-privacy-scan
openadapt-privacy-scan --self-test
openadapt-privacy-scan --root /path/to/repo
```

`[scan]` adds no packages. The scanner is stdlib, so `import openadapt_privacy.scan`
works on a bare `pip install openadapt-privacy` and does not load Presidio,
spaCy, or Pillow. `python -m openadapt_privacy.scan` is the same CLI.

```python
from pathlib import Path
from openadapt_privacy.scan import scan_tree, self_test

hits = scan_tree(Path(".")) # ["ohip-dashed\tfile.txt:3", ...]
self_test() # 0 ok, 1 a rule stayed silent
```

`--self-test` plants fixtures under `/tmp` and exits 1 if a rule does not fire.
Matching OHIP examples are not stored in the library; they're built at runtime.

Forbidden directory names: `recordings`, `captures`, `screenshots`,
`retinology`, `.private`. Forbidden suffixes include `.rdp`, `.db`, and
common media (`.png`, `.mp4`, and the rest of the set in `scan.py`).

`openadapt_privacy/gitleaks.toml` and `openadapt_privacy/phi-patterns.txt`
carry the same rules for gitleaks / git-secrets.

## Read this before you rely on it

Scrubbing is one control inside a reviewed egress process. It is not a
Expand Down Expand Up @@ -218,6 +253,9 @@ given string depends on the text around it, so measure rather than assume.

```
openadapt_privacy/
├── scan.py # git/CI regex gate (stdlib; no Presidio)
├── gitleaks.toml # same rules for gitleaks
├── phi-patterns.txt # same rules for git-secrets
├── base.py # ScrubbingProvider, TextScrubbingMixin
├── config.py # PrivacyConfig
├── loaders.py # Recording, Action, Screenshot, RecordingLoader
Expand Down
65 changes: 37 additions & 28 deletions openadapt_privacy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,47 @@
from importlib import metadata
from typing import Any

from openadapt_privacy.base import (
Modality,
ScrubbingProvider,
ScrubbingProviderFactory,
ScrubbingProviderUnavailable,
TextScrubbingMixin,
)
from openadapt_privacy.config import PrivacyConfig, ScrubbingPolicyChanged, config
from openadapt_privacy.loaders import (
Action,
DictRecordingLoader,
Recording,
RecordingLoader,
Screenshot,
UnscrubbedScreenshot,
)
from openadapt_privacy.pipelines.dicts import DictScrubber, scrub_dict, scrub_list_dicts
from openadapt_privacy.providers import ScrubProvider

try:
__version__ = metadata.version("openadapt-privacy")
except metadata.PackageNotFoundError: # pragma: no cover - source checkout only
# Never report a hard-coded version. An unmeasurable version is reported as
# unknown so a caller cannot mistake a stale literal for the installed one.
__version__ = "unknown"

# Names re-exported from optional-dependency modules. They are resolved lazily
# so that importing the package stays cheap, but a consumer writing
# ``from openadapt_privacy import PresidioScrubbingProvider`` must succeed
# whenever the package is installed. Before this indirection existed, that
# import raised ImportError even on a complete install, and downstream callers
# read the ImportError as "openadapt-privacy is not installed" and silently
# disabled PII/PHI scrubbing.
# Re-exports stay lazy so `import openadapt_privacy.scan` does not load
# Pillow, Presidio, or spaCy. `from openadapt_privacy import X` still works.
# Presidio names were already lazy: a failed import used to be read as
# "openadapt-privacy is not installed", and callers then skipped scrubbing.
_LAZY_EXPORTS = {
"Modality": "openadapt_privacy.base",
"ScrubbingProvider": "openadapt_privacy.base",
"ScrubbingProviderFactory": "openadapt_privacy.base",
"ScrubbingProviderUnavailable": "openadapt_privacy.base",
"TextScrubbingMixin": "openadapt_privacy.base",
"PrivacyConfig": "openadapt_privacy.config",
"ScrubbingPolicyChanged": "openadapt_privacy.config",
"config": "openadapt_privacy.config",
"Action": "openadapt_privacy.loaders",
"DictRecordingLoader": "openadapt_privacy.loaders",
"Recording": "openadapt_privacy.loaders",
"RecordingLoader": "openadapt_privacy.loaders",
"Screenshot": "openadapt_privacy.loaders",
"UnscrubbedScreenshot": "openadapt_privacy.loaders",
"DictScrubber": "openadapt_privacy.pipelines.dicts",
"scrub_dict": "openadapt_privacy.pipelines.dicts",
"scrub_list_dicts": "openadapt_privacy.pipelines.dicts",
"ScrubProvider": "openadapt_privacy.providers",
"PresidioScrubbingProvider": "openadapt_privacy.providers.presidio",
"PrivacyModelUnavailable": "openadapt_privacy.providers.presidio",
}

_PRESIDIO_EXPORTS = frozenset(
{
"PresidioScrubbingProvider",
"PrivacyModelUnavailable",
}
)


def __getattr__(name: str) -> Any:
"""Resolve lazily re-exported provider symbols.
Expand All @@ -64,11 +67,17 @@ def __getattr__(name: str) -> Any:
try:
module = importlib.import_module(module_path)
except ImportError as exc:
extra_hint = ""
if name in _PRESIDIO_EXPORTS:
extra_hint = (
" Install the provider dependencies with: "
"pip install 'openadapt-privacy[presidio]'"
)
raise ImportError(
f"openadapt-privacy {__version__} is installed, but {name!r} could not be "
f"imported from {module_path!r}: {exc}. Do not treat this as an absent "
"package: scrubbing is unavailable and must not be skipped silently. "
"Install the provider dependencies with: pip install 'openadapt-privacy[presidio]'"
"package: scrubbing is unavailable and must not be skipped silently."
f"{extra_hint}"
) from exc
value = getattr(module, name)
globals()[name] = value
Expand Down
68 changes: 68 additions & 0 deletions openadapt_privacy/gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
title = "openadapt-privacy git scan"

[extend]
useDefault = true

# Keywords are omitted on purpose. A keyword prefilter would skip a CSV that
# contains an OHIP-shaped number and no "ohip" token.

[[rules]]
id = "ohip-dashed"
description = "OHIP-shaped number (four-three-three, optional version letters)"
regex = '''\b\d{4}-\d{3}-\d{3}(?:-[A-Za-z]{1,2})?\b'''

[[rules]]
id = "ohip-spaced"
description = "OHIP-shaped number with spaces"
regex = '''\b\d{4} \d{3} \d{3}\b'''

[[rules]]
id = "ohip-dotted"
description = "OHIP-shaped number with dots"
regex = '''\b\d{4}\.\d{3}\.\d{3}\b'''

[[rules]]
id = "ohip-labeled-digits"
description = "OHIP label followed by a 10-digit number"
regex = '''(?i)\bohip\b.{0,24}\d{10}\b'''

[[rules]]
id = "chart-id-assigned"
description = "Chart id / number with a value"
regex = '''(?i)\bchart[_ -]?(?:id|no|num|number)\s*[:=#]\s*[A-Za-z0-9]'''

[[rules]]
id = "chart-hash"
description = "Chart hash identifier"
regex = '''(?i)\bchart\s*#\s*[A-Za-z0-9]'''

[[rules]]
id = "mrn-assigned"
description = "MRN with a value"
regex = '''(?i)\bmrn\s*[:=#]\s*[A-Za-z0-9]'''

[[rules]]
id = "unc-path"
description = "Windows UNC path"
regex = '''\\\\[A-Za-z0-9._-]+\\[A-Za-z0-9]'''

[[rules]]
id = "rdp-full-address"
description = "RDP full address / gateway hostname key"
regex = '''(?i)(?:full address|alternate full address|gatewayhostname)\s*:\s*s\s*:'''

[[rules]]
id = "rdp-env-host"
description = "RDP hostname environment key"
regex = '''(?i)\b(?:RDP_HOST|RDP_HOSTNAME|RDP_SERVER|MSTSC_HOST)\s*='''

[[rules]]
id = "mstsc-host"
description = "mstsc /v hostname"
regex = '''(?i)mstsc(?:\.exe)?\s+/v:'''

[[rules]]
id = "rdp-file"
description = "Remote Desktop connection file"
path = '''(?i)\.rdp$'''
regex = '''(?s).{0,}'''
22 changes: 22 additions & 0 deletions openadapt_privacy/phi-patterns.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# git-secrets / gitleaks / detect-secrets pattern file for git-scan PHI shapes.
# Load with: git-secrets --add-provider -- git-secrets --pattern-file phi-patterns.txt
# Do not put a matching example on the right-hand side of these lines.

# OHIP-shaped (four digits, three, three; optional two-letter version)
[0-9]{4}-[0-9]{3}-[0-9]{3}(-[A-Za-z]{1,2})?
[0-9]{4} [0-9]{3} [0-9]{3}
[0-9]{4}\.[0-9]{3}\.[0-9]{3}
(?i)\bohip\b.{0,24}[0-9]{10}

# Chart identifiers (require a value, not the word "chart" alone)
(?i)\bchart[_ -]?(id|no|num|number)[ \t]*[:=#][ \t]*[A-Za-z0-9]
(?i)\bchart[ \t]*#[ \t]*[A-Za-z0-9]
(?i)\bmrn[ \t]*[:=#][ \t]*[A-Za-z0-9]

# Windows UNC path (two backslashes, host, share)
\\\\[A-Za-z0-9._-]+\\[A-Za-z0-9]

# RDP hostname keys and mstsc
(?i)(full address|alternate full address|gatewayhostname)[ \t]*:[ \t]*s[ \t]*:
(?i)\b(RDP_HOST|RDP_HOSTNAME|RDP_SERVER|MSTSC_HOST)[ \t]*=
(?i)mstsc(\.exe)?[ \t]+/v:
Loading
Loading