ciphergate-cli 0.13.0__tar.gz → 0.14.0__tar.gz

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 (151) hide show
  1. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/CHANGELOG.md +18 -1
  2. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/PKG-INFO +6 -2
  3. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/README.md +5 -1
  4. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/detector-coverage.md +6 -2
  5. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/__init__.py +1 -1
  6. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/cli.py +54 -5
  7. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/config.py +13 -0
  8. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/batch.py +29 -6
  9. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/inspector.py +1 -1
  10. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/__init__.py +12 -3
  11. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_batch.py +91 -0
  12. ciphergate_cli-0.14.0/tests/integration/test_presidio_real.py +138 -0
  13. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/.gitignore +0 -0
  14. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/LICENSE +0 -0
  15. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/NOTICE +0 -0
  16. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/SECURITY.md +0 -0
  17. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/architecture.md +0 -0
  18. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/claude-code-tooling.md +0 -0
  19. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/examples.md +0 -0
  20. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/limitations.md +0 -0
  21. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/policy-schema.md +0 -0
  22. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/security-and-privacy.md +0 -0
  23. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/threat-model.md +0 -0
  24. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.csv +0 -0
  25. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.json +0 -0
  26. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.jsonl +0 -0
  27. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.log +0 -0
  28. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.csv +0 -0
  29. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.json +0 -0
  30. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.jsonl +0 -0
  31. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.log +0 -0
  32. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/dev_local.yml +0 -0
  33. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/developer_logs.yml +0 -0
  34. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/external_ai.yml +0 -0
  35. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/financial_template.yml +0 -0
  36. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/healthcare_template.yml +0 -0
  37. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/local_debug.yml +0 -0
  38. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/multi_environment.yml +0 -0
  39. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/prd_external_ai.yml +0 -0
  40. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/stg_test.yml +0 -0
  41. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/test_dataset.yml +0 -0
  42. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/pyproject.toml +0 -0
  43. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/scripts/benchmark/README.md +0 -0
  44. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/__main__.py +0 -0
  45. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/constants.py +0 -0
  46. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/__init__.py +0 -0
  47. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/base.py +0 -0
  48. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/custom_regex.py +0 -0
  49. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/dates.py +0 -0
  50. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/email.py +0 -0
  51. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/field_names.py +0 -0
  52. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/iban.py +0 -0
  53. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/ip_address.py +0 -0
  54. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/mac_address.py +0 -0
  55. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/network.py +0 -0
  56. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/payment_card.py +0 -0
  57. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/phone.py +0 -0
  58. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/presidio_ner.py +0 -0
  59. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/regex_safety.py +0 -0
  60. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/registry.py +0 -0
  61. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/secrets.py +0 -0
  62. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/ssn.py +0 -0
  63. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/errors.py +0 -0
  64. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/__init__.py +0 -0
  65. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/context.py +0 -0
  66. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/finding.py +0 -0
  67. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/policy.py +0 -0
  68. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/report.py +0 -0
  69. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/__init__.py +0 -0
  70. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/atomic_output.py +0 -0
  71. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/paths.py +0 -0
  72. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/planner.py +0 -0
  73. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/rescan.py +0 -0
  74. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/sanitizer.py +0 -0
  75. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/stdio.py +0 -0
  76. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/streams.py +0 -0
  77. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/__init__.py +0 -0
  78. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/context.py +0 -0
  79. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/engine.py +0 -0
  80. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/loader.py +0 -0
  81. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/precedence.py +0 -0
  82. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/templates.py +0 -0
  83. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/validator.py +0 -0
  84. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/py.typed +0 -0
  85. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/accesslog_reader.py +0 -0
  86. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/base.py +0 -0
  87. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/csv_reader.py +0 -0
  88. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/json_reader.py +0 -0
  89. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/jsonl_reader.py +0 -0
  90. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/jsonlog_reader.py +0 -0
  91. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/line_structure.py +0 -0
  92. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/logfmt_reader.py +0 -0
  93. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/stacktrace_reader.py +0 -0
  94. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/syslog_reader.py +0 -0
  95. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/text_reader.py +0 -0
  96. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/__init__.py +0 -0
  97. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/audit.py +0 -0
  98. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/next_steps.py +0 -0
  99. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/safe_text.py +0 -0
  100. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/serializer.py +0 -0
  101. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/terminal.py +0 -0
  102. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/templates/starter_policy.yml +0 -0
  103. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/__init__.py +0 -0
  104. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/date_shift.py +0 -0
  105. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/generalize.py +0 -0
  106. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/keys.py +0 -0
  107. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/pseudonymize.py +0 -0
  108. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/redact.py +0 -0
  109. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/registry.py +0 -0
  110. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/replace.py +0 -0
  111. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/__init__.py +0 -0
  112. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/conftest.py +0 -0
  113. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/__init__.py +0 -0
  114. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_api_batch_streams.py +0 -0
  115. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_atomic_output.py +0 -0
  116. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_cli_e2e.py +0 -0
  117. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_domain_templates.py +0 -0
  118. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_environment_matrix.py +0 -0
  119. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_pipelines.py +0 -0
  120. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_stdio.py +0 -0
  121. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/regression/__init__.py +0 -0
  122. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/regression/test_known_cases.py +0 -0
  123. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/__init__.py +0 -0
  124. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_key_file.py +0 -0
  125. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_no_network.py +0 -0
  126. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_no_raw_value_leaks.py +0 -0
  127. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_path_safety.py +0 -0
  128. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_policy_fail_closed.py +0 -0
  129. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_review_findings.py +0 -0
  130. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/__init__.py +0 -0
  131. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_benchmark.py +0 -0
  132. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_ci_supply_chain.py +0 -0
  133. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_claude_tooling.py +0 -0
  134. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_git_tooling.py +0 -0
  135. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_publish_workflow.py +0 -0
  136. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/__init__.py +0 -0
  137. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_accesslog.py +0 -0
  138. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_detectors.py +0 -0
  139. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_jsonlog.py +0 -0
  140. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_logfmt.py +0 -0
  141. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_next_steps.py +0 -0
  142. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_engine.py +0 -0
  143. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_precedence.py +0 -0
  144. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_templates.py +0 -0
  145. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_validation.py +0 -0
  146. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_presidio_ner.py +0 -0
  147. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_properties.py +0 -0
  148. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_readers.py +0 -0
  149. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_stacktrace.py +0 -0
  150. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_syslog.py +0 -0
  151. {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_transforms.py +0 -0
@@ -6,6 +6,22 @@ versioning: [PEP 440](https://peps.python.org/pep-0440/) with SemVer meaning (pr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.0] - 2026-11-02
10
+
11
+ Adds `--extension-format` for mixed directories and tests the optional NER detector against a
12
+ real Presidio install. No existing behaviour changes.
13
+
14
+ ### Added
15
+
16
+ - `--extension-format EXT=FORMAT` on `inspect`, `plan`, `sanitize` and `rescan` (repeatable) reads
17
+ files with that extension as FORMAT, for directories and single files. It adds unknown
18
+ extensions or replaces built-in ones, and cannot be combined with `--format`. The Python API
19
+ takes `RuntimeSettings(extension_formats=...)`.
20
+ - Tests that run the optional NER detector against a real Presidio install with the
21
+ `en_core_web_sm` model, including a `sanitize` run with network access disabled. They skip
22
+ when Presidio is not installed. A new non-required CI job, `ner`, installs the extra and a
23
+ hash-pinned model and runs them.
24
+
9
25
  ## [0.13.0] - 2026-11-02
10
26
 
11
27
  Fewer random IDs are reported as payment cards. Cards with an unrecognised prefix and no card
@@ -363,7 +379,8 @@ Pre-release (alpha). Licensed under Apache-2.0. Not a final release: the securit
363
379
  See `docs/limitations.md`. Deferred: optional NER/Presidio detectors, stdin/stdout streaming,
364
380
  directory processing, structured log parsers, key files, reversible token vault, lossy decoding.
365
381
 
366
- [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.13.0...HEAD
382
+ [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.14.0...HEAD
383
+ [0.14.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.13.0...v0.14.0
367
384
  [0.13.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.12.0...v0.13.0
368
385
  [0.12.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.11.0...v0.12.0
369
386
  [0.11.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.10.0...v0.11.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ciphergate-cli
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Local-first, environment- and destination-aware inspection and sanitization of logs and test datasets.
5
5
  Project-URL: Homepage, https://github.com/aashishkumar-tech/ciphergate
6
6
  Project-URL: Source, https://github.com/aashishkumar-tech/ciphergate
@@ -50,7 +50,7 @@ vendor ticket. CipherGate replaces that with a local, explainable, scriptable ga
50
50
  inspect -> plan -> sanitize --apply -> rescan
51
51
  ```
52
52
 
53
- > **Status: 0.13.0 (alpha).** No detector finds every sensitive value. A passing run means the
53
+ > **Status: 0.14.0 (alpha).** No detector finds every sensitive value. A passing run means the
54
54
  > configured processing completed under the selected policy; it is **not** proof that the output
55
55
  > contains no sensitive information, and it is not a compliance (GDPR, HIPAA, PCI DSS, ...) or
56
56
  > anonymization guarantee. Policy templates must be reviewed before use.
@@ -295,6 +295,10 @@ ciphergate sanitize logs/ --policy ... --output build/logs.sanitized --apply
295
295
  `rescan` fails (exit 7) when it skipped any, and `sanitize` leaves them out of the output.
296
296
  `--format` on a directory applies to every file, and the run warns when that contradicts an
297
297
  extension (for example a CSV read as text loses its column names).
298
+ - `--extension-format EXT=FORMAT` (repeatable) reads files with that extension as FORMAT, for
299
+ example `--extension-format .dat=csv --extension-format .log=jsonlog`. It adds unknown extensions
300
+ and replaces built-in ones, works for single files too, and cannot be combined with `--format`.
301
+ From Python, pass `RuntimeSettings(extension_formats={".dat": InputFormat.CSV})`.
298
302
  At most 1000 files by default (`--max-files N`); more fails the run (exit 4) before anything
299
303
  is processed. An empty selection fails with `batch_empty` (exit 4).
300
304
  - Each file is processed exactly like a single-file run, in sorted path order; a failing file
@@ -11,7 +11,7 @@ vendor ticket. CipherGate replaces that with a local, explainable, scriptable ga
11
11
  inspect -> plan -> sanitize --apply -> rescan
12
12
  ```
13
13
 
14
- > **Status: 0.13.0 (alpha).** No detector finds every sensitive value. A passing run means the
14
+ > **Status: 0.14.0 (alpha).** No detector finds every sensitive value. A passing run means the
15
15
  > configured processing completed under the selected policy; it is **not** proof that the output
16
16
  > contains no sensitive information, and it is not a compliance (GDPR, HIPAA, PCI DSS, ...) or
17
17
  > anonymization guarantee. Policy templates must be reviewed before use.
@@ -256,6 +256,10 @@ ciphergate sanitize logs/ --policy ... --output build/logs.sanitized --apply
256
256
  `rescan` fails (exit 7) when it skipped any, and `sanitize` leaves them out of the output.
257
257
  `--format` on a directory applies to every file, and the run warns when that contradicts an
258
258
  extension (for example a CSV read as text loses its column names).
259
+ - `--extension-format EXT=FORMAT` (repeatable) reads files with that extension as FORMAT, for
260
+ example `--extension-format .dat=csv --extension-format .log=jsonlog`. It adds unknown extensions
261
+ and replaces built-in ones, works for single files too, and cannot be combined with `--format`.
262
+ From Python, pass `RuntimeSettings(extension_formats={".dat": InputFormat.CSV})`.
259
263
  At most 1000 files by default (`--max-files N`); more fails the run (exit 4) before anything
260
264
  is processed. An empty selection fails with `batch_empty` (exit 4).
261
265
  - Each file is processed exactly like a single-file run, in sorted path order; a failing file
@@ -28,8 +28,12 @@ Needs `pip install 'ciphergate-cli[ner]'` and a spaCy model installed by the use
28
28
  `python -m spacy download en_core_web_lg`). CipherGate checks that the model package is importable
29
29
  before importing Presidio and fails with `ner_model_missing` instead of letting Presidio download
30
30
  it. Strings with no letters or under two characters are not sent to the model. Tests
31
- (`tests/unit/test_presidio_ner.py`) use a fake engine. Presidio itself is **not** exercised in
32
- CI, so accuracy on real text has not been measured by this project.
31
+ in `tests/unit/test_presidio_ner.py` use a fake engine. `tests/integration/test_presidio_real.py`
32
+ runs the real Presidio with `en_core_web_sm`: it finds a common synthetic name and place, ignores
33
+ text with no entities, removes them in a `sanitize` run with network access disabled, and fails
34
+ closed when the model is missing. CI runs it in the `ner` job (not a required check). These are
35
+ smoke tests on a few sentences: accuracy on real text has not been measured by this project,
36
+ and the small model misses names that the larger `en_core_web_lg` may find.
33
37
 
34
38
  ## Text/log specifics
35
39
 
@@ -24,7 +24,7 @@ Supported public API (everything else is internal and may change without notice)
24
24
  rescan_directory(root, policy, context=None, *, recursive=False, strict=None) -> BatchReport
25
25
  """
26
26
 
27
- __version__ = "0.13.0"
27
+ __version__ = "0.14.0"
28
28
 
29
29
  from ciphergate.config import RuntimeSettings
30
30
  from ciphergate.constants import (
@@ -117,6 +117,15 @@ PolicyOpt = Annotated[Path, typer.Option("--policy", "-p", help="Policy YAML fil
117
117
  FormatOpt = Annotated[
118
118
  InputFormat | None, typer.Option("--format", help="Input format (default: from extension).")
119
119
  ]
120
+ ExtFormatOpt = Annotated[
121
+ list[str] | None,
122
+ typer.Option(
123
+ "--extension-format",
124
+ metavar="EXT=FORMAT",
125
+ help="Read files with this extension as FORMAT, e.g. .dat=csv or .log=jsonlog. "
126
+ "Repeatable. Cannot be combined with --format.",
127
+ ),
128
+ ]
120
129
  ReportOpt = Annotated[
121
130
  Path | None, typer.Option("--report", help="Write the privacy-safe report to this file.")
122
131
  ]
@@ -192,18 +201,52 @@ def _runtime(
192
201
  encoding: str | None,
193
202
  path_display: PathDisplayChoice | None,
194
203
  max_files: int | None = None,
204
+ extension_formats: list[str] | None = None,
205
+ fmt: InputFormat | None = None,
195
206
  ) -> RuntimeSettings:
207
+ mapping = _parse_extension_formats(extension_formats or [])
208
+ if mapping and fmt is not None:
209
+ raise UsageError(
210
+ "use either --format (one format for every file) or --extension-format, not both",
211
+ code="format_conflict",
212
+ )
196
213
  try:
197
214
  return RuntimeSettings().with_overrides(
198
215
  max_input_bytes=max_bytes,
199
216
  encoding=encoding,
200
217
  path_display=str(path_display) if path_display else None,
201
218
  max_batch_files=max_files,
219
+ extension_formats=mapping or None,
202
220
  )
203
221
  except ValueError as exc:
204
222
  raise UsageError(str(exc), code="invalid_runtime_setting") from None
205
223
 
206
224
 
225
+ def _parse_extension_formats(values: list[str]) -> dict[str, InputFormat]:
226
+ mapping: dict[str, InputFormat] = {}
227
+ for value in values:
228
+ ext, sep, name = value.partition("=")
229
+ ext = ext.strip().lower()
230
+ if not ext.startswith("."):
231
+ ext = "." + ext
232
+ try:
233
+ fmt = InputFormat(name.strip().lower())
234
+ except ValueError:
235
+ fmt = None
236
+ if not sep or fmt is None:
237
+ raise UsageError(
238
+ "--extension-format needs EXT=FORMAT with FORMAT one of " + "|".join(InputFormat),
239
+ code="invalid_extension_format",
240
+ )
241
+ if ext in mapping and mapping[ext] != fmt:
242
+ raise UsageError(
243
+ "--extension-format maps the same extension to two formats",
244
+ code="invalid_extension_format",
245
+ )
246
+ mapping[ext] = fmt
247
+ return mapping
248
+
249
+
207
250
  def _err(message: str) -> None:
208
251
  sys.stderr.write(escape_control(message)[:2000] + "\n")
209
252
 
@@ -501,6 +544,7 @@ def inspect_command(
501
544
  ],
502
545
  policy: PolicyOpt,
503
546
  fmt: FormatOpt = None,
547
+ extension_format: ExtFormatOpt = None,
504
548
  report: ReportOpt = None,
505
549
  report_format: ReportFormatOpt = None,
506
550
  environment: EnvOpt = None,
@@ -531,7 +575,7 @@ def inspect_command(
531
575
  report,
532
576
  report_format,
533
577
  _context(environment, target_environment, destination, use_case, profile),
534
- _runtime(max_bytes, encoding, path_display, max_files),
578
+ _runtime(max_bytes, encoding, path_display, max_files, extension_format, fmt),
535
579
  quiet,
536
580
  verbose,
537
581
  force,
@@ -547,6 +591,7 @@ def plan_command(
547
591
  ],
548
592
  policy: PolicyOpt,
549
593
  fmt: FormatOpt = None,
594
+ extension_format: ExtFormatOpt = None,
550
595
  report: ReportOpt = None,
551
596
  report_format: ReportFormatOpt = None,
552
597
  environment: EnvOpt = None,
@@ -577,7 +622,7 @@ def plan_command(
577
622
  report,
578
623
  report_format,
579
624
  _context(environment, target_environment, destination, use_case, profile),
580
- _runtime(max_bytes, encoding, path_display, max_files),
625
+ _runtime(max_bytes, encoding, path_display, max_files, extension_format, fmt),
581
626
  quiet,
582
627
  verbose,
583
628
  force,
@@ -604,6 +649,7 @@ def sanitize_command(
604
649
  bool, typer.Option("--apply", help="Actually write output (else plan).")
605
650
  ] = False,
606
651
  fmt: FormatOpt = None,
652
+ extension_format: ExtFormatOpt = None,
607
653
  report: ReportOpt = None,
608
654
  report_format: ReportFormatOpt = None,
609
655
  environment: EnvOpt = None,
@@ -636,7 +682,9 @@ def sanitize_command(
636
682
  "--output - uses stdout for data; write the JSON report with --report PATH",
637
683
  code="stdout_conflict",
638
684
  )
639
- runtime = _runtime(max_bytes, encoding, path_display)
685
+ runtime = _runtime(
686
+ max_bytes, encoding, path_display, extension_formats=extension_format, fmt=fmt
687
+ )
640
688
  _check_report_path(report, [p for p in (input_path, output) if not is_stdio(p)], force)
641
689
  loaded = load_policy(policy)
642
690
  options = SanitizeOptions(apply=apply, force=force, format=selected_fmt, runtime=runtime)
@@ -689,7 +737,7 @@ def sanitize_command(
689
737
  raise UsageError(
690
738
  "a directory INPUT needs --output DIR, not stdout", code="stdout_unsupported"
691
739
  )
692
- runtime = _runtime(max_bytes, encoding, path_display, max_files)
740
+ runtime = _runtime(max_bytes, encoding, path_display, max_files, extension_format, fmt)
693
741
  _check_batch_report_path(report, [input_path, output], force)
694
742
  batch = sanitize_directory(
695
743
  input_path,
@@ -731,6 +779,7 @@ def rescan_command(
731
779
  ),
732
780
  ] = None,
733
781
  fmt: FormatOpt = None,
782
+ extension_format: ExtFormatOpt = None,
734
783
  report: ReportOpt = None,
735
784
  report_format: ReportFormatOpt = None,
736
785
  environment: EnvOpt = None,
@@ -764,7 +813,7 @@ def rescan_command(
764
813
  report,
765
814
  report_format,
766
815
  _context(environment, target_environment, destination, use_case, profile),
767
- _runtime(max_bytes, encoding, path_display, max_files),
816
+ _runtime(max_bytes, encoding, path_display, max_files, extension_format, fmt),
768
817
  quiet,
769
818
  verbose,
770
819
  force,
@@ -2,10 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  from dataclasses import dataclass, field, replace
6
7
  from typing import Literal
7
8
 
9
+ from ciphergate.constants import InputFormat
10
+
8
11
  PathDisplay = Literal["basename", "redacted", "full"]
12
+ _EXTENSION_RE = re.compile(r"^\.[a-z0-9_-]{1,16}$")
9
13
 
10
14
  MIB = 1024 * 1024
11
15
 
@@ -27,6 +31,9 @@ class RuntimeSettings:
27
31
  encoding: str = "utf-8"
28
32
  csv_delimiter: str = ","
29
33
  path_display: PathDisplay = "basename"
34
+ # Extra or replacement extension -> format mappings, e.g. {".dat": csv}. Keys are
35
+ # lower-case single suffixes. An explicit --format still wins for every file.
36
+ extension_formats: dict[str, InputFormat] = field(default_factory=dict)
30
37
  # Stored for completeness of the run report; never changes decisions.
31
38
  extra: dict[str, str] = field(default_factory=dict)
32
39
 
@@ -44,6 +51,12 @@ class RuntimeSettings:
44
51
  raise ValueError(f"{name} must be a positive integer")
45
52
  if len(self.csv_delimiter) != 1 or self.csv_delimiter in '\r\n"':
46
53
  raise ValueError("csv_delimiter must be a single character other than quote or newline")
54
+ for ext in self.extension_formats:
55
+ if not _EXTENSION_RE.match(ext):
56
+ raise ValueError(
57
+ "extension_formats keys must look like '.dat' (a dot, then 1-16 "
58
+ "lower-case letters, digits, '_' or '-')"
59
+ )
47
60
 
48
61
  def with_overrides(self, **changes: object) -> RuntimeSettings:
49
62
  clean = {k: v for k, v in changes.items() if v is not None}
@@ -24,7 +24,7 @@ import shutil
24
24
  import stat
25
25
  import sys
26
26
  import tempfile
27
- from collections.abc import Callable
27
+ from collections.abc import Callable, Mapping
28
28
  from dataclasses import dataclass, field
29
29
  from pathlib import Path
30
30
  from typing import Any
@@ -171,7 +171,14 @@ def _is_link(entry: os.DirEntry[str]) -> bool:
171
171
  return False
172
172
 
173
173
 
174
- def discover(root: Path, *, recursive: bool, fmt: InputFormat | None, max_files: int) -> Discovery:
174
+ def discover(
175
+ root: Path,
176
+ *,
177
+ recursive: bool,
178
+ fmt: InputFormat | None,
179
+ max_files: int,
180
+ extensions: Mapping[str, InputFormat] | None = None,
181
+ ) -> Discovery:
175
182
  """List the files a batch run will process. Raises on limits; never follows links."""
176
183
  try:
177
184
  st = os.lstat(root)
@@ -210,7 +217,7 @@ def discover(root: Path, *, recursive: bool, fmt: InputFormat | None, max_files:
210
217
  walk(rel, depth + 1)
211
218
  elif not entry.is_file(follow_symlinks=False):
212
219
  skipped.append((rel, "not_regular_file"))
213
- elif fmt is None and rel.suffix.lower() not in EXTENSION_FORMATS:
220
+ elif fmt is None and not _known_extension(rel, extensions):
214
221
  skipped.append((rel, "unsupported_extension"))
215
222
  else:
216
223
  files.append(rel)
@@ -226,6 +233,11 @@ def discover(root: Path, *, recursive: bool, fmt: InputFormat | None, max_files:
226
233
  return Discovery(root, sorted(files, key=lambda p: p.as_posix()), skipped)
227
234
 
228
235
 
236
+ def _known_extension(rel: Path, extensions: Mapping[str, InputFormat] | None) -> bool:
237
+ suffix = rel.suffix.lower()
238
+ return suffix in EXTENSION_FORMATS or suffix in (extensions or {})
239
+
240
+
229
241
  def display_relative(rel: Path, index: int, root: Path, mode: str) -> str:
230
242
  if mode == "redacted":
231
243
  return f"<redacted:{index:04d}>{rel.suffix}"
@@ -291,7 +303,8 @@ def coverage_warnings(
291
303
  f"{unknown} file(s) were skipped and not scanned because their extension is not "
292
304
  "recognised"
293
305
  + ("; they are not in the output" if sanitize else "")
294
- + "; rename them, or pass --format to read every file as one format"
306
+ + "; map them with --extension-format EXT=FORMAT, or pass --format to read every "
307
+ "file as one format"
295
308
  )
296
309
  if fmt is not None:
297
310
  mismatched = sum(
@@ -332,7 +345,13 @@ def analyze_directory(
332
345
  ) -> BatchReport:
333
346
  """Batch ``inspect`` / ``plan`` / ``rescan``. Read-only, like the single-file commands."""
334
347
  root = Path(root)
335
- discovery = discover(root, recursive=recursive, fmt=fmt, max_files=runtime.max_batch_files)
348
+ discovery = discover(
349
+ root,
350
+ recursive=recursive,
351
+ fmt=fmt,
352
+ max_files=runtime.max_batch_files,
353
+ extensions=runtime.extension_formats,
354
+ )
336
355
  _require_files(discovery)
337
356
 
338
357
  def run(path: Path, rel: Path) -> RunReport:
@@ -471,7 +490,11 @@ def sanitize_directory(
471
490
  runtime = options.runtime or RuntimeSettings()
472
491
  validate_output_directory(root, output)
473
492
  discovery = discover(
474
- root, recursive=recursive, fmt=options.format, max_files=runtime.max_batch_files
493
+ root,
494
+ recursive=recursive,
495
+ fmt=options.format,
496
+ max_files=runtime.max_batch_files,
497
+ extensions=runtime.extension_formats,
475
498
  )
476
499
  _require_files(discovery)
477
500
  mode = runtime.path_display
@@ -85,7 +85,7 @@ def prepare_run(
85
85
  ctx = resolve_context(policy, context)
86
86
  path = Path(input_path)
87
87
  validate_input_path(path, runtime.max_input_bytes)
88
- detected = detect_format(path, fmt)
88
+ detected = detect_format(path, fmt, runtime.extension_formats)
89
89
  options = ReaderOptions(
90
90
  runtime=runtime,
91
91
  policy_settings=policy.document.settings,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Mapping
5
6
  from pathlib import Path
6
7
  from typing import IO
7
8
 
@@ -49,11 +50,19 @@ _JSON_PREFIXES = (
49
50
  )
50
51
 
51
52
 
52
- def detect_format(path: Path, explicit: InputFormat | None = None) -> InputFormat:
53
- """Explicit ``--format`` wins; else use the extension, refusing contradicting content."""
53
+ def detect_format(
54
+ path: Path,
55
+ explicit: InputFormat | None = None,
56
+ extensions: Mapping[str, InputFormat] | None = None,
57
+ ) -> InputFormat:
58
+ """Explicit ``--format`` wins; then ``extensions``, then the built-in extension table.
59
+
60
+ Content that contradicts a JSON format is refused either way.
61
+ """
54
62
  if explicit is not None:
55
63
  return explicit
56
- fmt = EXTENSION_FORMATS.get(path.suffix.lower())
64
+ suffix = path.suffix.lower()
65
+ fmt = (extensions or {}).get(suffix) or EXTENSION_FORMATS.get(suffix)
57
66
  if fmt is None:
58
67
  raise UsageError(
59
68
  "cannot infer the input format from this file extension; pass --format "
@@ -405,3 +405,94 @@ def test_directory_summary_carries_next_steps(tmp_path: Path) -> None:
405
405
  (root / "app.log").write_text(SECRET_LINE, encoding="utf-8")
406
406
  r = invoke("inspect", root, "--policy", POLICIES / "developer_logs.yml")
407
407
  assert r.exit_code == 1 and "next: pass --environment" in r.stdout
408
+
409
+
410
+ # --------------------------------------------------------------------------- --extension-format
411
+
412
+
413
+ def _mixed(tmp_path: Path) -> Path:
414
+ root = tmp_path / "in"
415
+ root.mkdir()
416
+ (root / "users.dat").write_text("email\nalice@example.com\n", encoding="utf-8")
417
+ (root / "app.log").write_text(
418
+ '{"msg": "started", "user": "bob@example.com"}\n', encoding="utf-8"
419
+ )
420
+ return root
421
+
422
+
423
+ def test_extension_format_adds_an_extension_to_discovery(tmp_path: Path) -> None:
424
+ root = _mixed(tmp_path)
425
+ assert [
426
+ p.as_posix() for p in discover(root, recursive=False, fmt=None, max_files=10).files
427
+ ] == ["app.log"]
428
+ mapped = discover(
429
+ root, recursive=False, fmt=None, max_files=10, extensions={".dat": InputFormat.CSV}
430
+ )
431
+ assert [p.as_posix() for p in mapped.files] == ["app.log", "users.dat"] and not mapped.skipped
432
+
433
+
434
+ def test_extension_format_sets_each_files_format(tmp_path: Path, env) -> None:
435
+ root = _mixed(tmp_path)
436
+ report = tmp_path / "report.json"
437
+ r = invoke(
438
+ "inspect", root, *PRD_EXTERNAL, "--report", report,
439
+ "--extension-format", ".dat=csv", "--extension-format", "LOG=jsonlog",
440
+ env=env,
441
+ ) # fmt: skip
442
+ assert r.exit_code in (0, 1), r.stdout
443
+ formats = {f["path"]: f["report"]["input"]["format"] for f in _report(report)["files"]}
444
+ assert formats == {"app.log": "jsonlog", "users.dat": "csv"}
445
+ assert not _report(report)["skipped"]
446
+
447
+
448
+ def test_extension_format_works_for_a_single_file(tmp_path: Path, env) -> None:
449
+ src = _mixed(tmp_path) / "users.dat"
450
+ plain = invoke("inspect", src, *PRD_EXTERNAL, env=env)
451
+ assert plain.exit_code == 2 and "format_required" in plain.stdout + plain.stderr
452
+ report = tmp_path / "report.json"
453
+ mapped = invoke(
454
+ "inspect", src, *PRD_EXTERNAL, "--extension-format", ".dat=csv", "--report", report, env=env
455
+ )
456
+ assert mapped.exit_code in (0, 1) and _report(report)["input"]["format"] == "csv"
457
+
458
+
459
+ @pytest.mark.parametrize(
460
+ ("args", "code"),
461
+ [
462
+ (["--format", "text", "--extension-format", ".dat=csv"], "format_conflict"),
463
+ (["--extension-format", ".dat=xml"], "invalid_extension_format"),
464
+ (["--extension-format", ".dat"], "invalid_extension_format"),
465
+ (
466
+ ["--extension-format", ".dat=csv", "--extension-format", ".dat=text"],
467
+ "invalid_extension_format",
468
+ ),
469
+ (["--extension-format", "../x=csv"], "invalid_runtime_setting"),
470
+ ],
471
+ )
472
+ def test_bad_extension_format_is_a_usage_error(
473
+ args: list[str], code: str, tmp_path: Path, env
474
+ ) -> None:
475
+ r = invoke("inspect", _mixed(tmp_path), *PRD_EXTERNAL, *args, env=env)
476
+ assert r.exit_code == 2 and code in r.stdout + r.stderr
477
+
478
+
479
+ @pytest.mark.parametrize("command", ["plan", "rescan"])
480
+ def test_extension_format_on_other_commands(command: str, tmp_path: Path, env) -> None:
481
+ root = _mixed(tmp_path)
482
+ report = tmp_path / "report.json"
483
+ invoke(
484
+ command, root, *PRD_EXTERNAL, "--extension-format", ".dat=csv", "--report", report, env=env
485
+ )
486
+ assert [f["path"] for f in _report(report)["files"]] == ["app.log", "users.dat"]
487
+
488
+
489
+ def test_sanitize_directory_with_extension_format(tmp_path: Path, env) -> None:
490
+ root = _mixed(tmp_path)
491
+ out = tmp_path / "out"
492
+ r = invoke(
493
+ "sanitize", root, "--policy", POLICIES / "dev_local.yml", "-o", out, "--apply",
494
+ "--extension-format", ".dat=csv", env=env,
495
+ ) # fmt: skip
496
+ assert r.exit_code == 0, r.stdout
497
+ assert sorted(p.name for p in out.iterdir()) == ["app.log", "users.dat"]
498
+ assert "alice@example.com" not in (out / "users.dat").read_text(encoding="utf-8")
@@ -0,0 +1,138 @@
1
+ """The optional NER detector against a real Presidio install (skipped unless one is present).
2
+
3
+ The unit tests in ``tests/unit/test_presidio_ner.py`` use a fake engine. These run the real
4
+ ``presidio-analyzer`` with the small ``en_core_web_sm`` spaCy model. Install both yourself to run
5
+ them (CI does this in the ``ner`` job); CipherGate never downloads a model. The NER model is
6
+ statistical, so the assertions use common, clearly person-like synthetic names and check that
7
+ something was found, not exact scores.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import json
14
+ import os
15
+ import socket
16
+ from collections.abc import Iterator
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+ from typer.testing import CliRunner
21
+
22
+ from ciphergate.cli import app
23
+ from ciphergate.detectors import presidio_ner
24
+ from ciphergate.detectors.presidio_ner import PresidioDetector, load_analyzer
25
+ from ciphergate.models.finding import ScanItem
26
+ from ciphergate.models.policy import PresidioSettings
27
+ from tests.conftest import MINIMAL_POLICY
28
+
29
+ MODEL = "en_core_web_sm"
30
+ # CI sets this so a broken install fails the job instead of silently skipping every test.
31
+ REQUIRED = os.environ.get("CIPHERGATE_REQUIRE_NER") == "1"
32
+
33
+ pytestmark = [
34
+ pytest.mark.skipif(
35
+ not REQUIRED and importlib.util.find_spec("presidio_analyzer") is None,
36
+ reason="optional 'ner' extra (presidio-analyzer) not installed",
37
+ ),
38
+ pytest.mark.skipif(
39
+ not REQUIRED and importlib.util.find_spec(MODEL) is None,
40
+ reason=f"spaCy model {MODEL} not installed",
41
+ ),
42
+ ]
43
+
44
+ NAME = "John Smith" # synthetic; a common name the small model recognises reliably
45
+ TEXT = f"ticket opened by {NAME} in London after the export failed\n"
46
+
47
+ NER_POLICY = MINIMAL_POLICY.replace(
48
+ "settings: {strict: false}\n",
49
+ "settings: {strict: false}\n"
50
+ "detectors:\n"
51
+ f" presidio: {{enabled: true, model: {MODEL}, min_score: 0.5}}\n",
52
+ ).replace(
53
+ "defaults:\n",
54
+ " - id: redact-coarse-location\n"
55
+ " priority: 100\n"
56
+ " match: {categories: [location.coarse]}\n"
57
+ " action: redact\n"
58
+ "defaults:\n",
59
+ )
60
+
61
+ runner = CliRunner()
62
+
63
+
64
+ @pytest.fixture
65
+ def fresh_analyzer() -> Iterator[None]:
66
+ # Build the engine inside each test, so the no-network test covers construction too.
67
+ load_analyzer.cache_clear()
68
+ yield
69
+ load_analyzer.cache_clear()
70
+
71
+
72
+ def _item(text: str) -> ScanItem:
73
+ return ScanItem(record_index=0, line=1, field_path="", field_name=None, value=text)
74
+
75
+
76
+ def test_real_engine_finds_a_person_and_a_place(fresh_analyzer: None) -> None:
77
+ detector = PresidioDetector(PresidioSettings(enabled=True, model=MODEL, min_score=0.5))
78
+ findings = detector.detect(_item(TEXT))
79
+ spans = {
80
+ TEXT[f.location.span[0] : f.location.span[1]]: f.category
81
+ for f in findings
82
+ if f.location.span
83
+ }
84
+ assert spans.get(NAME) == "pii.person_name"
85
+ assert spans.get("London") == "location.coarse"
86
+ assert all(f.detector == "presidio_ner" and 0 < f.confidence <= 1 for f in findings)
87
+
88
+
89
+ def test_real_engine_ignores_text_without_entities(fresh_analyzer: None) -> None:
90
+ detector = PresidioDetector(PresidioSettings(enabled=True, model=MODEL, min_score=0.5))
91
+ assert detector.detect(_item("export failed with status 500 after 3 retries")) == []
92
+
93
+
94
+ def test_sanitize_with_real_ner_removes_the_name_offline(
95
+ fresh_analyzer: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
96
+ ) -> None:
97
+ def deny(*_: object, **__: object) -> None:
98
+ raise AssertionError("network access attempted")
99
+
100
+ for name in ("socket", "create_connection", "getaddrinfo", "gethostbyname"):
101
+ monkeypatch.setattr(socket, name, deny)
102
+
103
+ policy = tmp_path / "ner.yml"
104
+ policy.write_text(NER_POLICY, encoding="utf-8")
105
+ src = tmp_path / "app.log"
106
+ src.write_text(TEXT, encoding="utf-8")
107
+ out, report = tmp_path / "out.log", tmp_path / "report.json"
108
+ r = runner.invoke(
109
+ app,
110
+ [
111
+ *("sanitize", str(src), "--policy", str(policy), "-o", str(out)),
112
+ *("--report", str(report), "--apply"),
113
+ ],
114
+ )
115
+ assert r.exit_code == 0, r.stdout
116
+ sanitized = out.read_text(encoding="utf-8")
117
+ assert NAME not in sanitized and "London" not in sanitized
118
+ assert "[REDACTED:pii.person_name]" in sanitized
119
+ assert "export failed" in sanitized # the debugging context survives
120
+ data = json.loads(report.read_text(encoding="utf-8"))
121
+ assert data["summary"]["by_category"].get("pii.person_name", 0) >= 1
122
+ for text in (r.stdout, report.read_text(encoding="utf-8")):
123
+ assert NAME not in text and "London" not in text
124
+
125
+
126
+ def test_missing_model_fails_closed_with_the_real_package(
127
+ fresh_analyzer: None, tmp_path: Path
128
+ ) -> None:
129
+ policy = tmp_path / "ner.yml"
130
+ policy.write_text(NER_POLICY.replace(MODEL, "en_core_web_not_installed"), encoding="utf-8")
131
+ src = tmp_path / "app.log"
132
+ src.write_text(TEXT, encoding="utf-8")
133
+ out = tmp_path / "out.log"
134
+ args = ["sanitize", str(src), "--policy", str(policy), "-o", str(out), "--apply"]
135
+ r = runner.invoke(app, args)
136
+ assert r.exit_code == 6 and "ner_model_missing" in r.stdout + r.stderr
137
+ assert not out.exists()
138
+ assert presidio_ner._installed("presidio_analyzer")
File without changes
File without changes