ciphergate-cli 0.10.0__tar.gz → 0.11.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 (148) hide show
  1. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/CHANGELOG.md +15 -1
  2. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/PKG-INFO +3 -2
  3. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/README.md +2 -1
  4. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/limitations.md +3 -1
  5. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/policy-schema.md +6 -0
  6. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/__init__.py +1 -1
  7. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/cli.py +4 -1
  8. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/constants.py +4 -0
  9. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/validator.py +45 -0
  10. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/reporting/terminal.py +20 -4
  11. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_cli_e2e.py +22 -0
  12. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_policy_validation.py +66 -0
  13. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/.gitignore +0 -0
  14. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/LICENSE +0 -0
  15. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/NOTICE +0 -0
  16. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/SECURITY.md +0 -0
  17. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/architecture.md +0 -0
  18. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/claude-code-tooling.md +0 -0
  19. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/detector-coverage.md +0 -0
  20. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/examples.md +0 -0
  21. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/security-and-privacy.md +0 -0
  22. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/docs/threat-model.md +0 -0
  23. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/expected/sample.dev_local_dev.csv +0 -0
  24. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/expected/sample.dev_local_dev.json +0 -0
  25. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/expected/sample.dev_local_dev.jsonl +0 -0
  26. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/expected/sample.dev_local_dev.log +0 -0
  27. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/sample.csv +0 -0
  28. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/sample.json +0 -0
  29. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/sample.jsonl +0 -0
  30. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/examples/sample.log +0 -0
  31. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/dev_local.yml +0 -0
  32. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/developer_logs.yml +0 -0
  33. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/external_ai.yml +0 -0
  34. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/financial_template.yml +0 -0
  35. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/healthcare_template.yml +0 -0
  36. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/local_debug.yml +0 -0
  37. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/multi_environment.yml +0 -0
  38. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/prd_external_ai.yml +0 -0
  39. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/stg_test.yml +0 -0
  40. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/policies/test_dataset.yml +0 -0
  41. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/pyproject.toml +0 -0
  42. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/scripts/benchmark/README.md +0 -0
  43. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/__main__.py +0 -0
  44. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/config.py +0 -0
  45. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/__init__.py +0 -0
  46. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/base.py +0 -0
  47. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/custom_regex.py +0 -0
  48. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/dates.py +0 -0
  49. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/email.py +0 -0
  50. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/field_names.py +0 -0
  51. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/iban.py +0 -0
  52. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/ip_address.py +0 -0
  53. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/mac_address.py +0 -0
  54. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/network.py +0 -0
  55. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/payment_card.py +0 -0
  56. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/phone.py +0 -0
  57. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/presidio_ner.py +0 -0
  58. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/regex_safety.py +0 -0
  59. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/registry.py +0 -0
  60. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/secrets.py +0 -0
  61. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/detectors/ssn.py +0 -0
  62. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/errors.py +0 -0
  63. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/models/__init__.py +0 -0
  64. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/models/context.py +0 -0
  65. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/models/finding.py +0 -0
  66. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/models/policy.py +0 -0
  67. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/models/report.py +0 -0
  68. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/__init__.py +0 -0
  69. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/atomic_output.py +0 -0
  70. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/batch.py +0 -0
  71. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/inspector.py +0 -0
  72. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/paths.py +0 -0
  73. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/planner.py +0 -0
  74. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/rescan.py +0 -0
  75. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/sanitizer.py +0 -0
  76. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/stdio.py +0 -0
  77. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/pipeline/streams.py +0 -0
  78. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/__init__.py +0 -0
  79. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/context.py +0 -0
  80. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/engine.py +0 -0
  81. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/loader.py +0 -0
  82. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/precedence.py +0 -0
  83. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/policy/templates.py +0 -0
  84. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/py.typed +0 -0
  85. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/__init__.py +0 -0
  86. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/accesslog_reader.py +0 -0
  87. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/base.py +0 -0
  88. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/csv_reader.py +0 -0
  89. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/json_reader.py +0 -0
  90. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/jsonl_reader.py +0 -0
  91. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/jsonlog_reader.py +0 -0
  92. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/line_structure.py +0 -0
  93. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/logfmt_reader.py +0 -0
  94. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/stacktrace_reader.py +0 -0
  95. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/syslog_reader.py +0 -0
  96. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/readers/text_reader.py +0 -0
  97. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/reporting/__init__.py +0 -0
  98. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/reporting/audit.py +0 -0
  99. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/reporting/safe_text.py +0 -0
  100. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/reporting/serializer.py +0 -0
  101. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/templates/starter_policy.yml +0 -0
  102. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/__init__.py +0 -0
  103. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/date_shift.py +0 -0
  104. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/generalize.py +0 -0
  105. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/keys.py +0 -0
  106. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/pseudonymize.py +0 -0
  107. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/redact.py +0 -0
  108. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/registry.py +0 -0
  109. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/src/ciphergate/transforms/replace.py +0 -0
  110. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/__init__.py +0 -0
  111. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/conftest.py +0 -0
  112. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/__init__.py +0 -0
  113. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_api_batch_streams.py +0 -0
  114. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_atomic_output.py +0 -0
  115. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_batch.py +0 -0
  116. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_domain_templates.py +0 -0
  117. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_environment_matrix.py +0 -0
  118. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_pipelines.py +0 -0
  119. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/integration/test_stdio.py +0 -0
  120. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/regression/__init__.py +0 -0
  121. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/regression/test_known_cases.py +0 -0
  122. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/__init__.py +0 -0
  123. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/test_key_file.py +0 -0
  124. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/test_no_network.py +0 -0
  125. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/test_no_raw_value_leaks.py +0 -0
  126. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/test_path_safety.py +0 -0
  127. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/test_policy_fail_closed.py +0 -0
  128. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/security/test_review_findings.py +0 -0
  129. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/tooling/__init__.py +0 -0
  130. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/tooling/test_benchmark.py +0 -0
  131. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/tooling/test_ci_supply_chain.py +0 -0
  132. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/tooling/test_claude_tooling.py +0 -0
  133. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/tooling/test_git_tooling.py +0 -0
  134. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/tooling/test_publish_workflow.py +0 -0
  135. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/__init__.py +0 -0
  136. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_accesslog.py +0 -0
  137. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_detectors.py +0 -0
  138. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_jsonlog.py +0 -0
  139. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_logfmt.py +0 -0
  140. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_policy_engine.py +0 -0
  141. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_policy_precedence.py +0 -0
  142. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_policy_templates.py +0 -0
  143. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_presidio_ner.py +0 -0
  144. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_properties.py +0 -0
  145. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_readers.py +0 -0
  146. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_stacktrace.py +0 -0
  147. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_syslog.py +0 -0
  148. {ciphergate_cli-0.10.0 → ciphergate_cli-0.11.0}/tests/unit/test_transforms.py +0 -0
@@ -6,6 +6,19 @@ versioning: [PEP 440](https://peps.python.org/pep-0440/) with SemVer meaning (pr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2026-10-31
10
+
11
+ Makes a clean rescan harder to mistake for proof of safety. Scripts that read `PASSED` from the
12
+ `rescan` terminal output need updating; exit codes and the JSON report do not change.
13
+
14
+ ### Changed
15
+
16
+ - The `rescan` terminal summary no longer prints `PASSED`. It says `NO GATING FINDINGS`, or
17
+ `NOT ENFORCED` when gating findings remain with strict off, and states what rescan cannot see.
18
+ Exit codes and the JSON report's `status` are unchanged.
19
+ - `validate-policy` warns when a rule or default handles `credential.secret` with anything other
20
+ than `block` or `review` toward a possibly untrusted destination.
21
+
9
22
  ## [0.10.0] - 2026-10-30
10
23
 
11
24
  Fixes a password leak in nested values and detects more credentials and identifiers. A run that
@@ -315,7 +328,8 @@ Pre-release (alpha). Licensed under Apache-2.0. Not a final release: the securit
315
328
  See `docs/limitations.md`. Deferred: optional NER/Presidio detectors, stdin/stdout streaming,
316
329
  directory processing, structured log parsers, key files, reversible token vault, lossy decoding.
317
330
 
318
- [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.10.0...HEAD
331
+ [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.11.0...HEAD
332
+ [0.11.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.10.0...v0.11.0
319
333
  [0.10.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.9.0...v0.10.0
320
334
  [0.9.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.8.0...v0.9.0
321
335
  [0.8.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.7.1...v0.8.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ciphergate-cli
3
- Version: 0.10.0
3
+ Version: 0.11.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.10.0 (alpha).** No detector finds every sensitive value. A passing run means the
53
+ > **Status: 0.11.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.
@@ -154,6 +154,7 @@ ciphergate sanitize examples/sample.csv --policy policies/prd_external_ai.yml \
154
154
  --output build/sample.sanitized.csv --report build/sample.report.json --apply
155
155
 
156
156
  # 5. Rescan the output (read-only). Exit 7 if gating residual findings remain.
157
+ # A clean result only means no gating findings. It does not show the output is safe.
157
158
  ciphergate rescan build/sample.sanitized.csv --policy policies/prd_external_ai.yml
158
159
  ```
159
160
 
@@ -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.10.0 (alpha).** No detector finds every sensitive value. A passing run means the
14
+ > **Status: 0.11.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.
@@ -115,6 +115,7 @@ ciphergate sanitize examples/sample.csv --policy policies/prd_external_ai.yml \
115
115
  --output build/sample.sanitized.csv --report build/sample.report.json --apply
116
116
 
117
117
  # 5. Rescan the output (read-only). Exit 7 if gating residual findings remain.
118
+ # A clean result only means no gating findings. It does not show the output is safe.
118
119
  ciphergate rescan build/sample.sanitized.csv --policy policies/prd_external_ai.yml
119
120
  ```
120
121
 
@@ -5,7 +5,9 @@
5
5
  - Detection is incomplete. Names, addresses, government IDs and bank accounts are not detected by
6
6
  value in free text; secrets in custom formats or encoded forms are missed. See
7
7
  [detector-coverage.md](detector-coverage.md).
8
- - A clean rescan is a quality gate, not proof of safety. Rescan cannot verify date-shifted values
8
+ - A clean rescan is a quality gate, not proof of safety. The terminal summary says "no gating
9
+ findings" for this reason, while the JSON report keeps `status: passed` for CI. Names and
10
+ addresses in free text are often missed. Rescan cannot verify date-shifted values
9
11
  (reported as `unverifiable`) and recognises CipherGate's own markers/pseudonyms/replacement
10
12
  constants as transformed output.
11
13
  - Confidence values are uncalibrated engineering estimates.
@@ -122,6 +122,12 @@ A context selector never matches missing context.
122
122
  | `review` | — | run needs human decision, nothing written |
123
123
  | `tokenize` | — | **rejected**: no token vault in this release |
124
124
 
125
+ A rule or default that applies `redact`, `replace`, or any other non-`block`, non-`review` action
126
+ to `credential.secret` toward a possibly untrusted destination validates with a warning, because
127
+ a detector can match only part of a secret. A destination that is not pinned counts as possibly
128
+ untrusted. A `hard: true` block that covers every untrusted destination, with no selectors other
129
+ than `categories` and `destinations`, removes the warning.
130
+
125
131
  ## Precedence (deterministic)
126
132
 
127
133
  1. Engine hard constraints: missing/invalid context gates; credential never allowed (forced
@@ -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.10.0"
27
+ __version__ = "0.11.0"
28
28
 
29
29
  from ciphergate.config import RuntimeSettings
30
30
  from ciphergate.constants import (
@@ -749,7 +749,10 @@ def rescan_command(
749
749
  no_color: NoColorOpt = False,
750
750
  debug: DebugOpt = False,
751
751
  ) -> None:
752
- """Report residual findings in an existing output. Read-only; never alters the file."""
752
+ """Report residual findings in an existing output. Read-only; never alters the file.
753
+
754
+ No gating findings does not mean the output is safe: rescan only sees what detectors match.
755
+ """
753
756
  _configure_logging(verbose, debug)
754
757
  _guard(
755
758
  debug,
@@ -272,3 +272,7 @@ DISCLAIMER = (
272
272
  "It does not prove that the output contains no sensitive information; "
273
273
  "detectors can miss values."
274
274
  )
275
+ RESCAN_LIMIT = (
276
+ "rescan only sees what the enabled detectors match. No gating findings does not mean the "
277
+ "output is safe; names and addresses in free text are often missed."
278
+ )
@@ -225,6 +225,34 @@ def _effective_destinations(doc: PolicyDocument, rule_dests: list[Destination] |
225
225
  return {str(d) for d in Destination}
226
226
 
227
227
 
228
+ def _hard_blocked_destinations(doc: PolicyDocument, categories: frozenset[str]) -> set[str]:
229
+ """Destinations where a hard rule blocks every credential finding, whatever its field."""
230
+ blocked: set[str] = set()
231
+ for rule in doc.rules:
232
+ m = rule.match
233
+ if (
234
+ rule.hard
235
+ and rule.action == Action.BLOCK
236
+ and m.categories is not None
237
+ and CREDENTIAL_CATEGORY in _selector_can_cover(m.categories, categories)
238
+ and set(m.model_dump(exclude_none=True)) <= {"categories", "destinations"}
239
+ ):
240
+ blocked |= _effective_destinations(doc, m.destinations)
241
+ return blocked
242
+
243
+
244
+ def _relaxes_credentials(
245
+ doc: PolicyDocument,
246
+ action: Action,
247
+ rule_dests: list[Destination] | None,
248
+ hard_blocked: set[str],
249
+ ) -> bool:
250
+ if action in (Action.BLOCK, Action.REVIEW):
251
+ return False
252
+ reachable = _effective_destinations(doc, rule_dests) - hard_blocked
253
+ return bool(reachable & {str(d) for d in UNTRUSTED_DESTINATIONS})
254
+
255
+
228
256
  def _selector_can_cover(selectors: list[str] | None, categories: frozenset[str]) -> set[str]:
229
257
  if selectors is None:
230
258
  return set(categories)
@@ -335,6 +363,7 @@ def compile_policy(raw: dict[str, Any], *, digest: str, source_name: str | None
335
363
  required_keys: set[str] = set()
336
364
  replacement_values: set[str] = set()
337
365
  seen_rules: set[str] = set()
366
+ hard_blocked = _hard_blocked_destinations(doc, categories)
338
367
  for i, rule in enumerate(doc.rules):
339
368
  where = f"rules[{i}]"
340
369
  if rule.id in seen_rules:
@@ -394,6 +423,15 @@ def compile_policy(raw: dict[str, Any], *, digest: str, source_name: str | None
394
423
  "credential_unsafe_action",
395
424
  )
396
425
  )
426
+ if (
427
+ CREDENTIAL_CATEGORY in covered
428
+ and m.categories is not None
429
+ and _relaxes_credentials(doc, rule.action, m.destinations, hard_blocked)
430
+ ):
431
+ warnings.append(
432
+ f"rule '{rule.id}' applies '{rule.action}' to credential.secret toward a possibly "
433
+ "untrusted destination instead of blocking; detection can miss part of a secret"
434
+ )
397
435
  if rule.action == Action.ALLOW:
398
436
  dests = _effective_destinations(doc, m.destinations)
399
437
  untrusted = dests & {str(d) for d in UNTRUSTED_DESTINATIONS}
@@ -492,6 +530,13 @@ def compile_policy(raw: dict[str, Any], *, digest: str, source_name: str | None
492
530
  "credential_unsafe_action",
493
531
  )
494
532
  )
533
+ if CREDENTIAL_CATEGORY in covered and _relaxes_credentials(
534
+ doc, spec.action, None, hard_blocked
535
+ ):
536
+ warnings.append(
537
+ f"default '{sel}' applies '{spec.action}' to credential.secret toward a possibly "
538
+ "untrusted destination instead of blocking; detection can miss part of a secret"
539
+ )
495
540
  if isinstance(params, PseudonymizeParams | DateShiftParams):
496
541
  required_keys.add(params.key_env)
497
542
  if isinstance(params, ReplaceParams) and params.value is not None:
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
- from ciphergate.constants import DISCLAIMER
7
+ from ciphergate.constants import DISCLAIMER, RESCAN_LIMIT
8
8
  from ciphergate.models.report import RunReport
9
9
  from ciphergate.pipeline.batch import BatchReport
10
10
  from ciphergate.reporting.safe_text import escape_control
@@ -31,9 +31,24 @@ def _location(loc: dict[str, Any]) -> str:
31
31
  return ", ".join(parts)
32
32
 
33
33
 
34
+ def _rescan_verdict(rescan: dict[str, Any]) -> str:
35
+ if rescan["failed"]:
36
+ return "FAILED, gating findings remain"
37
+ if rescan["gating_by_category"]:
38
+ return "NOT ENFORCED, gating findings remain but strict is off"
39
+ return "no gating findings"
40
+
41
+
42
+ def _headline(d: dict[str, Any]) -> str:
43
+ # Never print a bare PASSED for rescan: it reads as "this output is safe".
44
+ if d["command"] == "rescan" and d["rescan"] and not d["rescan"]["failed"]:
45
+ return _rescan_verdict(d["rescan"]).split(",")[0].upper()
46
+ return str(d["status"]).upper()
47
+
48
+
34
49
  def render_summary(report: RunReport, *, verbose: bool = False) -> str:
35
50
  d = report.to_dict()
36
- lines = [f"CipherGate {d['command']}: {d['status'].upper()} (exit {d['exit_code']})"]
51
+ lines = [f"CipherGate {d['command']}: {_headline(d)} (exit {d['exit_code']})"]
37
52
  inp = d["input"]
38
53
  lines.append(
39
54
  f" input: {escape_control(str(inp['path']))} [{inp['format']}, "
@@ -89,12 +104,13 @@ def render_summary(report: RunReport, *, verbose: bool = False) -> str:
89
104
  )
90
105
  if d["rescan"]:
91
106
  r = d["rescan"]
92
- verdict = "FAILED" if r["failed"] else "passed"
93
107
  lines.append(
94
- f" rescan: {verdict} (strict={r['strict']}) residuals: {_counts(r['counts'])}"
108
+ f" rescan: {_rescan_verdict(r)} (strict={r['strict']}) "
109
+ f"residuals: {_counts(r['counts'])}"
95
110
  )
96
111
  if r["gating_by_category"]:
97
112
  lines.append(f" gating residuals: {_counts(r['gating_by_category'])}")
113
+ lines.append(f" rescan limit: {RESCAN_LIMIT}")
98
114
  out = d["output"]
99
115
  if out:
100
116
  if out.get("written"):
@@ -197,6 +197,28 @@ def test_sanitize_missing_key_exit_6(tmp_path: Path, no_key_env: None) -> None:
197
197
  assert r.exit_code == 6 and not out.exists() and "missing" in r.stdout
198
198
 
199
199
 
200
+ def test_rescan_summary_never_says_a_bare_pass(tmp_path: Path) -> None:
201
+ policy = POLICIES / "prd_external_ai.yml"
202
+ clean = tmp_path / "clean.log"
203
+ clean.write_text("service started\n", encoding="utf-8")
204
+ r = invoke("rescan", clean, "--policy", policy)
205
+ assert r.exit_code == 0
206
+ assert "NO GATING FINDINGS" in r.stdout and "does not mean the output is safe" in r.stdout
207
+ assert "PASSED" not in r.stdout and "passed" not in r.stdout.replace("--", "")
208
+
209
+ report = tmp_path / "report.json"
210
+ assert invoke("rescan", clean, "--policy", policy, "--report", report).exit_code == 0
211
+ assert json.loads(report.read_text(encoding="utf-8"))["status"] == "passed"
212
+
213
+
214
+ def test_rescan_summary_says_when_gating_findings_are_not_enforced(tmp_path: Path) -> None:
215
+ src = tmp_path / "raw.csv"
216
+ shutil.copy(EXAMPLES / "sample.csv", src)
217
+ r = invoke("rescan", src, "--policy", POLICIES / "prd_external_ai.yml", "--no-strict")
218
+ assert r.exit_code == 0
219
+ assert "NOT ENFORCED" in r.stdout and "PASSED" not in r.stdout
220
+
221
+
200
222
  def test_rescan_exit_7_and_read_only(tmp_path: Path) -> None:
201
223
  src = tmp_path / "raw.csv"
202
224
  shutil.copy(EXAMPLES / "sample.csv", src)
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  import pytest
9
9
 
10
+ from ciphergate import load_policy
10
11
  from ciphergate.errors import PolicyValidationError
11
12
  from ciphergate.policy.validator import Policy, validate_policy
12
13
  from tests.conftest import MINIMAL_POLICY, POLICIES
@@ -138,6 +139,71 @@ def test_credential_generalize_rejected(policy_text: Callable[[str], Policy]) ->
138
139
  assert "credential_unsafe_action" in codes(e)
139
140
 
140
141
 
142
+ CREDENTIAL_RULE = "rules:\n - id: r\n match: {categories: [credential.secret]}\n"
143
+
144
+
145
+ @pytest.mark.parametrize(
146
+ ("action", "extra", "warns"),
147
+ [
148
+ ("redact", "", True),
149
+ ("block", "", False),
150
+ ("review", "", False),
151
+ (
152
+ "redact",
153
+ " match: {categories: [credential.secret], destinations: [local_dev]}\n",
154
+ False,
155
+ ),
156
+ (
157
+ "redact",
158
+ " match: {categories: [credential.secret], destinations: [external_ai]}\n",
159
+ True,
160
+ ),
161
+ ],
162
+ )
163
+ def test_credential_rule_short_of_block_warns(
164
+ policy_text: Callable[[str], Policy], action: str, extra: str, warns: bool
165
+ ) -> None:
166
+ head = "rules:\n - id: r\n" + extra if extra else CREDENTIAL_RULE
167
+ p = policy_text(HEAD + head + f" action: {action}\n")
168
+ assert any("instead of blocking" in w for w in p.warnings) is warns
169
+
170
+
171
+ def test_credential_default_short_of_block_warns(policy_text: Callable[[str], Policy]) -> None:
172
+ p = policy_text(HEAD + "defaults:\n credential.*: {action: redact}\n")
173
+ assert any("instead of blocking" in w for w in p.warnings)
174
+
175
+
176
+ HARD_BLOCK = (
177
+ " - id: b\n priority: 1000\n hard: true\n"
178
+ " match:\n categories: [credential.secret]\n{scope}"
179
+ " action: block\n"
180
+ )
181
+ EVERY_UNTRUSTED = " destinations: [external_vendor, external_ai, public_repository, unknown]\n"
182
+
183
+
184
+ def test_hard_block_on_every_untrusted_destination_silences_the_warning(
185
+ policy_text: Callable[[str], Policy],
186
+ ) -> None:
187
+ rule = CREDENTIAL_RULE + " action: redact\n"
188
+ covered = policy_text(HEAD + rule + HARD_BLOCK.format(scope=EVERY_UNTRUSTED))
189
+ assert not any("instead of blocking" in w for w in covered.warnings)
190
+
191
+
192
+ def test_partial_hard_block_keeps_the_warning(policy_text: Callable[[str], Policy]) -> None:
193
+ rule = CREDENTIAL_RULE + " action: redact\n"
194
+ scope = " destinations: [external_ai]\n"
195
+ partial = policy_text(HEAD + rule + HARD_BLOCK.format(scope=scope))
196
+ assert any("instead of blocking" in w for w in partial.warnings)
197
+ fields = " field_names: [password]\n"
198
+ narrow = policy_text(HEAD + rule + HARD_BLOCK.format(scope=fields))
199
+ assert any("instead of blocking" in w for w in narrow.warnings)
200
+
201
+
202
+ @pytest.mark.parametrize("path", sorted(POLICIES.glob("*.yml")), ids=lambda p: p.stem)
203
+ def test_shipped_policies_do_not_relax_credentials(path: Path) -> None:
204
+ assert not any("instead of blocking" in w for w in load_policy(path).warnings)
205
+
206
+
141
207
  def test_high_risk_allow_needs_acknowledgement(policy_text: Callable[[str], Policy]) -> None:
142
208
  rule = "rules:\n - id: r\n match: {categories: [pii.email]}\n action: allow\n"
143
209
  e = rejects(policy_text, HEAD + rule)
File without changes
File without changes