ciphergate-cli 0.12.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.12.0 → ciphergate_cli-0.14.0}/CHANGELOG.md +32 -1
  2. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/PKG-INFO +6 -2
  3. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/README.md +5 -1
  4. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/detector-coverage.md +7 -3
  5. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/__init__.py +1 -1
  6. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/cli.py +54 -5
  7. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/config.py +13 -0
  8. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/payment_card.py +26 -5
  9. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/batch.py +29 -6
  10. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/inspector.py +1 -1
  11. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/__init__.py +12 -3
  12. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_batch.py +91 -0
  13. ciphergate_cli-0.14.0/tests/integration/test_presidio_real.py +138 -0
  14. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_detectors.py +50 -4
  15. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/.gitignore +0 -0
  16. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/LICENSE +0 -0
  17. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/NOTICE +0 -0
  18. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/SECURITY.md +0 -0
  19. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/architecture.md +0 -0
  20. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/claude-code-tooling.md +0 -0
  21. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/examples.md +0 -0
  22. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/limitations.md +0 -0
  23. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/policy-schema.md +0 -0
  24. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/security-and-privacy.md +0 -0
  25. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/docs/threat-model.md +0 -0
  26. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.csv +0 -0
  27. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.json +0 -0
  28. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.jsonl +0 -0
  29. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.log +0 -0
  30. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/sample.csv +0 -0
  31. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/sample.json +0 -0
  32. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/sample.jsonl +0 -0
  33. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/examples/sample.log +0 -0
  34. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/dev_local.yml +0 -0
  35. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/developer_logs.yml +0 -0
  36. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/external_ai.yml +0 -0
  37. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/financial_template.yml +0 -0
  38. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/healthcare_template.yml +0 -0
  39. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/local_debug.yml +0 -0
  40. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/multi_environment.yml +0 -0
  41. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/prd_external_ai.yml +0 -0
  42. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/stg_test.yml +0 -0
  43. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/policies/test_dataset.yml +0 -0
  44. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/pyproject.toml +0 -0
  45. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/scripts/benchmark/README.md +0 -0
  46. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/__main__.py +0 -0
  47. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/constants.py +0 -0
  48. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/__init__.py +0 -0
  49. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/base.py +0 -0
  50. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/custom_regex.py +0 -0
  51. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/dates.py +0 -0
  52. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/email.py +0 -0
  53. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/field_names.py +0 -0
  54. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/iban.py +0 -0
  55. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/ip_address.py +0 -0
  56. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/mac_address.py +0 -0
  57. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/network.py +0 -0
  58. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/phone.py +0 -0
  59. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/presidio_ner.py +0 -0
  60. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/regex_safety.py +0 -0
  61. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/registry.py +0 -0
  62. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/secrets.py +0 -0
  63. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/ssn.py +0 -0
  64. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/errors.py +0 -0
  65. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/__init__.py +0 -0
  66. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/context.py +0 -0
  67. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/finding.py +0 -0
  68. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/policy.py +0 -0
  69. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/report.py +0 -0
  70. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/__init__.py +0 -0
  71. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/atomic_output.py +0 -0
  72. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/paths.py +0 -0
  73. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/planner.py +0 -0
  74. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/rescan.py +0 -0
  75. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/sanitizer.py +0 -0
  76. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/stdio.py +0 -0
  77. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/streams.py +0 -0
  78. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/__init__.py +0 -0
  79. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/context.py +0 -0
  80. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/engine.py +0 -0
  81. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/loader.py +0 -0
  82. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/precedence.py +0 -0
  83. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/templates.py +0 -0
  84. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/validator.py +0 -0
  85. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/py.typed +0 -0
  86. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/accesslog_reader.py +0 -0
  87. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/base.py +0 -0
  88. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/csv_reader.py +0 -0
  89. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/json_reader.py +0 -0
  90. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/jsonl_reader.py +0 -0
  91. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/jsonlog_reader.py +0 -0
  92. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/line_structure.py +0 -0
  93. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/logfmt_reader.py +0 -0
  94. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/stacktrace_reader.py +0 -0
  95. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/syslog_reader.py +0 -0
  96. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/text_reader.py +0 -0
  97. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/__init__.py +0 -0
  98. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/audit.py +0 -0
  99. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/next_steps.py +0 -0
  100. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/safe_text.py +0 -0
  101. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/serializer.py +0 -0
  102. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/terminal.py +0 -0
  103. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/templates/starter_policy.yml +0 -0
  104. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/__init__.py +0 -0
  105. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/date_shift.py +0 -0
  106. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/generalize.py +0 -0
  107. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/keys.py +0 -0
  108. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/pseudonymize.py +0 -0
  109. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/redact.py +0 -0
  110. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/registry.py +0 -0
  111. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/replace.py +0 -0
  112. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/__init__.py +0 -0
  113. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/conftest.py +0 -0
  114. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/__init__.py +0 -0
  115. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_api_batch_streams.py +0 -0
  116. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_atomic_output.py +0 -0
  117. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_cli_e2e.py +0 -0
  118. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_domain_templates.py +0 -0
  119. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_environment_matrix.py +0 -0
  120. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_pipelines.py +0 -0
  121. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/integration/test_stdio.py +0 -0
  122. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/regression/__init__.py +0 -0
  123. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/regression/test_known_cases.py +0 -0
  124. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/__init__.py +0 -0
  125. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/test_key_file.py +0 -0
  126. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/test_no_network.py +0 -0
  127. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/test_no_raw_value_leaks.py +0 -0
  128. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/test_path_safety.py +0 -0
  129. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/test_policy_fail_closed.py +0 -0
  130. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/security/test_review_findings.py +0 -0
  131. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/tooling/__init__.py +0 -0
  132. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/tooling/test_benchmark.py +0 -0
  133. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/tooling/test_ci_supply_chain.py +0 -0
  134. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/tooling/test_claude_tooling.py +0 -0
  135. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/tooling/test_git_tooling.py +0 -0
  136. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/tooling/test_publish_workflow.py +0 -0
  137. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/__init__.py +0 -0
  138. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_accesslog.py +0 -0
  139. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_jsonlog.py +0 -0
  140. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_logfmt.py +0 -0
  141. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_next_steps.py +0 -0
  142. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_engine.py +0 -0
  143. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_precedence.py +0 -0
  144. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_templates.py +0 -0
  145. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_validation.py +0 -0
  146. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_presidio_ner.py +0 -0
  147. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_properties.py +0 -0
  148. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_readers.py +0 -0
  149. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_stacktrace.py +0 -0
  150. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_syslog.py +0 -0
  151. {ciphergate_cli-0.12.0 → ciphergate_cli-0.14.0}/tests/unit/test_transforms.py +0 -0
@@ -6,6 +6,35 @@ 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
+
25
+ ## [0.13.0] - 2026-11-02
26
+
27
+ Fewer random IDs are reported as payment cards. Cards with an unrecognised prefix and no card
28
+ keyword nearby are no longer reported, so check that your data is still covered.
29
+
30
+ ### Changed
31
+
32
+ - `luhn_card` reports fewer random IDs as cards. A Luhn-valid number now needs a known
33
+ issuer prefix or a card keyword nearby, and an issuer-prefixed number right after an ID
34
+ word such as `invoice` or `batch id` needs a card keyword. JCB, UnionPay, Diners Club,
35
+ Mir and Discover 644-649 prefixes are now recognised. Cards with other prefixes and no
36
+ keyword are no longer reported.
37
+
9
38
  ## [0.12.0] - 2026-11-02
10
39
 
11
40
  Blocked runs now say what to do next, and directory runs no longer hide files they never
@@ -350,7 +379,9 @@ Pre-release (alpha). Licensed under Apache-2.0. Not a final release: the securit
350
379
  See `docs/limitations.md`. Deferred: optional NER/Presidio detectors, stdin/stdout streaming,
351
380
  directory processing, structured log parsers, key files, reversible token vault, lossy decoding.
352
381
 
353
- [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.12.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
384
+ [0.13.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.12.0...v0.13.0
354
385
  [0.12.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.11.0...v0.12.0
355
386
  [0.11.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.10.0...v0.11.0
356
387
  [0.10.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.9.0...v0.10.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ciphergate-cli
3
- Version: 0.12.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.12.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.12.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
@@ -12,7 +12,7 @@ are engineering estimates mapped to bands (`low` < 0.5 <= `medium` < 0.8 <= `hig
12
12
  | `email_regex` | `pii.email` | `alice.test@example.com`, `alice.test%40example.com` | `a..b@example.com`, `user@localhost` | 0.9 | ASCII only; no IDN; obfuscated forms (`alice at example dot com`) missed; `%40` is the only percent-encoding recognised outside `accesslog` |
13
13
  | `phone_pattern` | `pii.phone` | `+1 202 555 0143` (high), `(202) 555-0143` (medium) | dates, IPs, card numbers, repeated digits, < 7 digits | 0.3 bare digits, 0.55–0.8 prefixed/formatted, +0.2 keyword | no numbering-plan validation; IDs can look like phones (low band) |
14
14
  | `ip_address` | `pii.ip_address` (global), `network.telemetry` (private/loopback/link-local) | `8.8.8.8`, `10.0.0.1`, `2606:4700::1111`, `fe80::1%eth0` | `256.1.1.1`, `std::vector`, `12:30:45` | 0.85 / 0.8 | version strings like `1.2.3.4` match; documentation ranges flagged with evidence |
15
- | `luhn_card` | `pii.payment_card` | `4111 1111 1111 1111` | Luhn-invalid, mixed separators, repeated digits | 0.4 Luhn-only, 0.8 issuer prefix, +0.15 keyword; labelled CVV/CVC values 0.85 | random IDs pass Luhn ~10% (low band); cards split across fields missed |
15
+ | `luhn_card` | `pii.payment_card` | `4111 1111 1111 1111` | Luhn-invalid, mixed separators, repeated digits | 0.8 issuer prefix (Visa, Mastercard, Amex, Discover, JCB, UnionPay, Diners, Mir), +0.15 keyword; 0.55 keyword without prefix; labelled CVV/CVC values 0.85 | a Luhn-valid number with no issuer prefix and no card keyword is not reported, nor is one right after an ID word (`invoice`, `batch id`, `order`) without a card keyword, so such cards are missed (for example Maestro without a keyword); cards split across fields missed |
16
16
  | `mac_address` | `pii.device_identifier` | `00:1A:2B:3C:4D:5E`, `00-1a-2b-3c-4d-5e`, `001a.2b3c.4d5e` | all-zero, broadcast, mixed separators, `12:30:45` | 0.85, 0.6 when all digits | bare 12-hex values are missed; digit-only IDs in this shape are flagged |
17
17
  | `iban` | `pii.bank_account` | a mod-97-valid IBAN, compact or space-grouped | wrong check digits, lowercase | 0.9 | country length and national format are not checked; about 1 in 97 random strings of this shape pass; lowercase forms are missed |
18
18
  | `ssn_pattern` | `pii.government_id` | `219-09-9999` | never-issued areas (`000`, `666`, `9xx`), group `00`, serial `0000`, undashed | 0.8, 0.95 with a keyword | US only; undashed numbers are missed; other dashed IDs in this shape are flagged |
@@ -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.12.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}
@@ -1,7 +1,9 @@
1
1
  """Payment-card-like detector: 13-19 digits, Luhn-valid, scored by issuer prefix and context.
2
2
 
3
- A Luhn-valid digit run without a known issuer prefix or context keyword is low confidence:
4
- long numeric identifiers pass Luhn roughly 10% of the time by chance.
3
+ Long numeric identifiers pass Luhn roughly 10% of the time by chance, so a Luhn-valid run is
4
+ reported only with a known issuer prefix or a card keyword nearby. An issuer-prefixed run right
5
+ after an identifier word such as "invoice" or "batch id" is also skipped unless a card keyword
6
+ is present. Cards with other prefixes (for example Maestro) and no keyword are missed.
5
7
  """
6
8
 
7
9
  from __future__ import annotations
@@ -16,6 +18,12 @@ _CVV_RE = re.compile(
16
18
  r"(?i)(?<![a-z0-9])(?:cvv2?|cvc2?)(?:\\?[\"'])?\s{0,3}[:=]\s{0,3}(?:\\?[\"'])?(\d{3,4})(?!\d)"
17
19
  )
18
20
  _KEYWORDS = ("card", "visa", "mastercard", "amex", "pan", "cc", "credit", "debit", "payment")
21
+ # Words that label a number as some other identifier. Whole words only: a substring match
22
+ # would let "paid" (which contains "id") hide a real card.
23
+ _ID_WORD_RE = re.compile(
24
+ r"(?i)(?<![a-z])(?:id|invoice|order|batch|total|trace|request|ref|seq|sequence|ticket|"
25
+ r"build|job|txn|transaction)(?![a-z])"
26
+ )
19
27
 
20
28
 
21
29
  def luhn_valid(digits: str) -> bool:
@@ -38,8 +46,15 @@ def known_issuer_prefix(digits: str) -> bool:
38
46
  return len(digits) == 16
39
47
  if two in (34, 37):
40
48
  return len(digits) == 15
41
- if digits.startswith("6011") or two == 65:
49
+ three = int(digits[:3])
50
+ if digits.startswith("6011") or two == 65 or 644 <= three <= 649:
42
51
  return len(digits) in (16, 19)
52
+ if 3528 <= four <= 3589 or two == 62: # JCB, UnionPay
53
+ return 16 <= len(digits) <= 19
54
+ if 300 <= three <= 305 or two in (36, 38, 39): # Diners Club
55
+ return 14 <= len(digits) <= 19
56
+ if 2200 <= four <= 2204: # Mir
57
+ return 16 <= len(digits) <= 19
43
58
  return False
44
59
 
45
60
 
@@ -71,12 +86,18 @@ class PaymentCardDetector:
71
86
  seps = {c for c in raw if c in " -"}
72
87
  if len(seps) > 1:
73
88
  continue
89
+ issuer = known_issuer_prefix(digits)
90
+ keyword = preceding_context(text, m.start(), _KEYWORDS, window=32)
91
+ if not keyword and (
92
+ not issuer or _ID_WORD_RE.search(text[max(0, m.start() - 16) : m.start()])
93
+ ):
94
+ continue
74
95
  evidence = ["pattern_match", "checksum_valid"]
75
96
  confidence = 0.4
76
- if known_issuer_prefix(digits):
97
+ if issuer:
77
98
  confidence = 0.8
78
99
  evidence.append("issuer_prefix")
79
- if preceding_context(text, m.start(), _KEYWORDS, window=32):
100
+ if keyword:
80
101
  confidence += 0.15
81
102
  evidence.append("context_keyword")
82
103
  out.append(
@@ -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")