diff --git a/README.md b/README.md index 6de490d..bea464e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/openadapt_privacy/__init__.py b/openadapt_privacy/__init__.py index 2d8abb2..d360d69 100644 --- a/openadapt_privacy/__init__.py +++ b/openadapt_privacy/__init__.py @@ -4,25 +4,6 @@ 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 @@ -30,18 +11,40 @@ # 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. @@ -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 diff --git a/openadapt_privacy/gitleaks.toml b/openadapt_privacy/gitleaks.toml new file mode 100644 index 0000000..b31a706 --- /dev/null +++ b/openadapt_privacy/gitleaks.toml @@ -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,}''' diff --git a/openadapt_privacy/phi-patterns.txt b/openadapt_privacy/phi-patterns.txt new file mode 100644 index 0000000..361e699 --- /dev/null +++ b/openadapt_privacy/phi-patterns.txt @@ -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: diff --git a/openadapt_privacy/scan.py b/openadapt_privacy/scan.py new file mode 100644 index 0000000..6e6e962 --- /dev/null +++ b/openadapt_privacy/scan.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Fail closed on PHI shapes, RDP hostnames, and forbidden paths. + +This module is the git/CI gate. It is stdlib-only: importing +``openadapt_privacy.scan`` must not load Presidio, spaCy, or Pillow. + +It must refuse a match, and it must refuse a silent miss: ``self_test()`` +plants fixtures in a temp dir and returns non-zero if any rule fails to fire. + +Do not put a matching example in this file. Fixtures are built at runtime. +""" + +from __future__ import annotations + +import argparse +import os +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +# Python regexes. Keep in sync with gitleaks.toml and phi-patterns.txt. +RULES: list[tuple[str, re.Pattern[str]]] = [ + ( + "ohip-dashed", + re.compile(r"\b\d{4}-\d{3}-\d{3}(?:-[A-Za-z]{1,2})?\b"), + ), + ("ohip-spaced", re.compile(r"\b\d{4} \d{3} \d{3}\b")), + ("ohip-dotted", re.compile(r"\b\d{4}\.\d{3}\.\d{3}\b")), + ("ohip-labeled-digits", re.compile(r"(?i)\bohip\b.{0,24}\d{10}\b")), + ( + "chart-id-assigned", + re.compile(r"(?i)\bchart[_ -]?(?:id|no|num|number)\s*[:=#]\s*[A-Za-z0-9]"), + ), + ("chart-hash", re.compile(r"(?i)\bchart\s*#\s*[A-Za-z0-9]")), + ("mrn-assigned", re.compile(r"(?i)\bmrn\s*[:=#]\s*[A-Za-z0-9]")), + ("unc-path", re.compile(r"\\\\[A-Za-z0-9._-]+\\[A-Za-z0-9]")), + ( + "rdp-full-address", + re.compile( + r"(?i)(?:full address|alternate full address|gatewayhostname)\s*:\s*s\s*:" + ), + ), + ( + "rdp-env-host", + re.compile(r"(?i)\b(?:RDP_HOST|RDP_HOSTNAME|RDP_SERVER|MSTSC_HOST)\s*="), + ), + ("mstsc-host", re.compile(r"(?i)mstsc(?:\.exe)?\s+/v:")), +] + +FORBIDDEN_SUFFIXES = { + ".rdp", + ".db", + ".sqlite", + ".sqlite3", + ".mdb", + ".accdb", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".tif", + ".tiff", + ".mp4", + ".webm", + ".mov", + ".mkv", + ".avi", + ".wav", + ".mp3", + ".m4a", +} + +FORBIDDEN_DIR_PARTS = { + "recordings", + "captures", + "screenshots", + "retinology", + ".private", +} + +SKIP_DIR_NAMES = {".git", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache"} + + +def _git_files(root: Path) -> list[Path] | None: + try: + out = subprocess.run( + [ + "git", + "-C", + str(root), + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ], + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + names = [n for n in out.stdout.split(b"\0") if n] + return [root / n.decode("utf-8", "surrogateescape") for n in names] + + +def _walk_files(root: Path) -> list[Path]: + files: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + files.append(Path(dirpath) / name) + return files + + +def iter_scan_files(root: Path) -> list[Path]: + tracked = _git_files(root) + if tracked is not None: + return [p for p in tracked if p.is_file()] + return [p for p in _walk_files(root) if p.is_file()] + + +def _is_binary(path: Path) -> bool: + try: + with path.open("rb") as fh: + chunk = fh.read(8192) + except OSError: + return True + return b"\0" in chunk + + +def _rel(root: Path, path: Path) -> str: + try: + return str(path.resolve().relative_to(root.resolve())) + except ValueError: + return str(path) + + +def scan_forbidden_paths(root: Path, files: list[Path]) -> list[str]: + hits: list[str] = [] + for path in files: + rel = _rel(root, path) + parts = Path(rel).parts + lower_parts = {p.lower() for p in parts} + suffix = Path(rel).suffix.lower() + if suffix in FORBIDDEN_SUFFIXES: + hits.append(f"forbidden-suffix\t{rel}") + if lower_parts & FORBIDDEN_DIR_PARTS: + hits.append(f"forbidden-dir\t{rel}") + return hits + + +def scan_contents(root: Path, files: list[Path]) -> list[str]: + hits: list[str] = [] + for path in files: + if _is_binary(path): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + hits.append(f"unreadable\t{_rel(root, path)}\t{exc}") + continue + for rule_id, pattern in RULES: + for match in pattern.finditer(text): + line_no = text.count("\n", 0, match.start()) + 1 + hits.append(f"{rule_id}\t{_rel(root, path)}:{line_no}") + break + return hits + + +def scan_tree(root: Path) -> list[str]: + """Return the same hit lines the clinic git scanner printed.""" + files = iter_scan_files(root) + return scan_forbidden_paths(root, files) + scan_contents(root, files) + + +def _write(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IRUSR) + + +def plant_fixtures(tmp: Path) -> dict[str, Path]: + """Build matching fixtures without storing those strings in this file.""" + four = "9" * 4 + three = "9" * 3 + ten = "9" * 10 + ohip_dash = f"{four}-{three}-{three}" + ohip_space = f"{four} {three} {three}" + ohip_dot = f"{four}.{three}.{three}" + planted = { + "ohip-dashed": tmp / "ohip-dashed.txt", + "ohip-spaced": tmp / "ohip-spaced.txt", + "ohip-dotted": tmp / "ohip-dotted.txt", + "ohip-labeled-digits": tmp / "ohip-labeled.txt", + "chart-id-assigned": tmp / "chart-id.txt", + "chart-hash": tmp / "chart-hash.txt", + "mrn-assigned": tmp / "mrn.txt", + "unc-path": tmp / "unc.txt", + "rdp-full-address": tmp / "rdp-address.txt", + "rdp-env-host": tmp / "rdp-env.txt", + "mstsc-host": tmp / "mstsc.txt", + "rdp-file": tmp / "session.rdp", + } + # Concatenate so this source file itself does not match the rules. + _write(planted["ohip-dashed"], ohip_dash + "\n") + _write(planted["ohip-spaced"], ohip_space + "\n") + _write(planted["ohip-dotted"], ohip_dot + "\n") + _write(planted["ohip-labeled-digits"], "OHIP " + ten + "\n") + _write(planted["chart-id-assigned"], "chart" + "_id: Z9\n") + _write(planted["chart-hash"], "chart " + "#Z9\n") + _write(planted["mrn-assigned"], "mr" + "n: Z9\n") + host = "testhost" + share = "share" + _write(planted["unc-path"], "\\\\" + host + "\\" + share + "\n") + _write(planted["rdp-full-address"], "full address" + ":s:" + host + "\n") + _write(planted["rdp-env-host"], "RDP_" + "HOST=" + host + "\n") + _write(planted["mstsc-host"], "mstsc " + "/v:" + host + "\n") + _write(planted["rdp-file"], "full address" + ":s:" + host + "\n") + return planted + + +def self_test() -> int: + """Plant fixtures in a temp dir. Return 1 if any rule stays silent.""" + with tempfile.TemporaryDirectory(prefix="openadapt-privacy-scan-selftest-") as raw: + tmp = Path(raw) + planted = plant_fixtures(tmp) + files = [p for p in planted.values() if p.is_file()] + content_hits = scan_contents(tmp, files) + path_hits = scan_forbidden_paths(tmp, files) + fired = {h.split("\t", 1)[0] for h in content_hits} + if any(h.startswith("forbidden-suffix\t") and h.endswith(".rdp") for h in path_hits): + fired.add("rdp-file") + required = set(planted) + missing = sorted(required - fired) + if missing: + print("self-test miss (scanner is blind):", ", ".join(missing), file=sys.stderr) + print("hits:", *content_hits, *path_hits, sep="\n", file=sys.stderr) + return 1 + print("self-test ok:", ", ".join(sorted(required))) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--self-test", + action="store_true", + help="plant fixtures in a temp dir and require every rule to fire", + ) + parser.add_argument( + "--root", + type=Path, + default=None, + help="tree to scan (default: current working directory)", + ) + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + + root = (args.root if args.root is not None else Path.cwd()).resolve() + hits = scan_tree(root) + if hits: + print("PHI / clinic-path scan failed:", file=sys.stderr) + for hit in hits: + print(hit, file=sys.stderr) + return 1 + print(f"PHI scan clean ({len(iter_scan_files(root))} files)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 3d67ade..8fc0223 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,12 +35,19 @@ presidio = [ "spacy>=3.7.0", ] +# Git/CI gate. Stdlib only; no extra packages. Clinic installs this extra to +# mean "the scanner, not Presidio". +scan = [] + dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.1.0", ] +[project.scripts] +openadapt-privacy-scan = "openadapt_privacy.scan:main" + [project.urls] Homepage = "https://github.com/OpenAdaptAI/openadapt-privacy" Repository = "https://github.com/OpenAdaptAI/openadapt-privacy" @@ -52,6 +59,10 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["openadapt_privacy"] +[tool.hatch.build.targets.wheel.force-include] +"openadapt_privacy/gitleaks.toml" = "openadapt_privacy/gitleaks.toml" +"openadapt_privacy/phi-patterns.txt" = "openadapt_privacy/phi-patterns.txt" + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/tests/test_scan.py b/tests/test_scan.py new file mode 100644 index 0000000..2a46b09 --- /dev/null +++ b/tests/test_scan.py @@ -0,0 +1,250 @@ +"""Git/CI scanner: OHIP, chart, UNC, RDP, path bans, fail-closed self-test.""" + +from __future__ import annotations + +import ast +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +from openadapt_privacy.scan import ( + FORBIDDEN_DIR_PARTS, + FORBIDDEN_SUFFIXES, + RULES, + main, + plant_fixtures, + scan_tree, + self_test, +) + +ROOT = Path(__file__).resolve().parents[1] +SCAN_PY = ROOT / "openadapt_privacy" / "scan.py" + + +def _git_init(path: Path) -> None: + subprocess.run( + ["git", "init"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + + +def _stage(path: Path) -> None: + subprocess.run( + ["git", "add", "-A", "-f"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + + +def _hit_ids(hits: list[str]) -> set[str]: + return {h.split("\t", 1)[0] for h in hits} + + +def test_scan_module_is_stdlib_only() -> None: + source = SCAN_PY.read_text(encoding="utf-8") + tree = ast.parse(source) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imported.add(alias.name.split(".", 1)[0]) + elif isinstance(node, ast.ImportFrom): + if node.level: + pytest.fail("scan.py must not use relative imports") + if node.module: + imported.add(node.module.split(".", 1)[0]) + imported.discard("__future__") + assert imported <= set(sys.stdlib_module_names) + for banned in ("PIL", "pillow", "presidio", "spacy", "openadapt_privacy"): + assert banned not in imported + + +def test_scan_source_has_no_kirill() -> None: + assert "kirill_" not in SCAN_PY.read_text(encoding="utf-8") + + +def test_scan_source_has_no_matching_ohip_example() -> None: + source = SCAN_PY.read_text(encoding="utf-8") + assert re.search(r"\b\d{4}-\d{3}-\d{3}\b", source) is None + assert re.search(r"\b\d{4} \d{3} \d{3}\b", source) is None + assert re.search(r"\b\d{4}\.\d{3}\.\d{3}\b", source) is None + + +def test_import_scan_does_not_load_pillow_presidio_or_spacy() -> None: + code = r""" +import builtins +import sys + +real_import = builtins.__import__ +blocked = ( + "PIL", + "presidio_analyzer", + "presidio_anonymizer", + "presidio_image_redactor", + "spacy", +) + +def guarded(name, *args, **kwargs): + root = name.split(".", 1)[0] + if name in blocked or root in blocked: + raise ImportError(f"blocked {name}") + return real_import(name, *args, **kwargs) + +builtins.__import__ = guarded +import openadapt_privacy.scan as scan +assert callable(scan.scan_tree) +assert callable(scan.self_test) +for name in blocked: + assert name not in sys.modules, name +print("ok") +""" + completed = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + assert completed.stdout.strip() == "ok" + + +def test_self_test_passes() -> None: + assert self_test() == 0 + + +def test_self_test_fail_closed_when_ohip_rule_is_blind(monkeypatch: pytest.MonkeyPatch) -> None: + blinded = [ + (rule_id, re.compile(r"(?!x)x") if rule_id == "ohip-dashed" else pattern) + for rule_id, pattern in RULES + ] + monkeypatch.setattr("openadapt_privacy.scan.RULES", blinded) + assert self_test() == 1 + + +def test_self_test_fail_closed_when_rdp_suffix_is_dropped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "openadapt_privacy.scan.FORBIDDEN_SUFFIXES", + FORBIDDEN_SUFFIXES - {".rdp"}, + ) + assert self_test() == 1 + + +def test_scan_tree_fires_each_planted_rule(tmp_path: Path) -> None: + _git_init(tmp_path) + planted = plant_fixtures(tmp_path) + _stage(tmp_path) + hits = scan_tree(tmp_path) + fired = _hit_ids(hits) + for rule_id, path in planted.items(): + if rule_id == "rdp-file": + assert any( + h.startswith("forbidden-suffix\t") and h.endswith(".rdp") for h in hits + ), hits + continue + rel = path.name + assert any(h.startswith(f"{rule_id}\t{rel}:") for h in hits), (rule_id, hits) + assert "ohip-dashed" in fired + assert "chart-id-assigned" in fired + assert "mrn-assigned" in fired + assert "unc-path" in fired + assert "rdp-full-address" in fired + assert "rdp-env-host" in fired + assert "mstsc-host" in fired + + +@pytest.mark.parametrize( + "dirname", + sorted(FORBIDDEN_DIR_PARTS), +) +def test_forbidden_dir_parts(tmp_path: Path, dirname: str) -> None: + _git_init(tmp_path) + nested = tmp_path / dirname + nested.mkdir() + (nested / "note.txt").write_text("ok\n", encoding="utf-8") + _stage(tmp_path) + hits = scan_tree(tmp_path) + assert any(h.startswith("forbidden-dir\t") and dirname in h for h in hits), hits + + +@pytest.mark.parametrize("suffix", [".rdp", ".db", ".png", ".mp4"]) +def test_forbidden_suffixes(tmp_path: Path, suffix: str) -> None: + _git_init(tmp_path) + (tmp_path / f"artifact{suffix}").write_text("x\n", encoding="utf-8") + _stage(tmp_path) + hits = scan_tree(tmp_path) + assert any(h.startswith("forbidden-suffix\t") and h.endswith(suffix) for h in hits), hits + + +def test_clean_tree_has_no_hits(tmp_path: Path) -> None: + _git_init(tmp_path) + (tmp_path / "readme.txt").write_text("no identifiers here\n", encoding="utf-8") + _stage(tmp_path) + assert scan_tree(tmp_path) == [] + + +def test_cli_self_test() -> None: + assert main(["--self-test"]) == 0 + + +def test_cli_root_exits_one_on_hits(tmp_path: Path) -> None: + _git_init(tmp_path) + plant_fixtures(tmp_path) + _stage(tmp_path) + assert main(["--root", str(tmp_path)]) == 1 + + +def test_cli_root_exits_zero_when_clean(tmp_path: Path) -> None: + _git_init(tmp_path) + (tmp_path / "ok.txt").write_text("hello\n", encoding="utf-8") + _stage(tmp_path) + assert main(["--root", str(tmp_path)]) == 0 + + +def test_module_cli_self_test() -> None: + completed = subprocess.run( + [sys.executable, "-m", "openadapt_privacy.scan", "--self-test"], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + assert "self-test ok:" in completed.stdout + + +def test_scan_extra_is_empty() -> None: + source = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r"(?m)^scan\s*=\s*\[(.*?)\]\s*$", source) + assert match is not None, source + body = re.sub(r"#.*", "", match.group(1)) + assert re.search(r"[A-Za-z0-9]", body) is None + + +def test_console_script_is_declared() -> None: + source = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert 'openadapt-privacy-scan = "openadapt_privacy.scan:main"' in source + + +def test_scan_is_not_reexported_from_package_init() -> None: + import openadapt_privacy + + assert "scan" not in openadapt_privacy.__all__ + assert "scan_tree" not in openadapt_privacy.__all__ + assert "scan" not in openadapt_privacy._LAZY_EXPORTS + assert "scan_tree" not in openadapt_privacy._LAZY_EXPORTS + + +def test_readme_keeps_presidio_caveat_and_has_scan_section() -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "## Git / CI scan (`[scan]`)" in readme + assert "synthetic, not clinical" in readme + assert "does not claim clinical validation" in readme