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.
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/CHANGELOG.md +18 -1
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/PKG-INFO +6 -2
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/README.md +5 -1
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/detector-coverage.md +6 -2
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/__init__.py +1 -1
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/cli.py +54 -5
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/config.py +13 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/batch.py +29 -6
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/inspector.py +1 -1
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/__init__.py +12 -3
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_batch.py +91 -0
- ciphergate_cli-0.14.0/tests/integration/test_presidio_real.py +138 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/.gitignore +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/LICENSE +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/NOTICE +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/SECURITY.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/architecture.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/claude-code-tooling.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/examples.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/limitations.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/policy-schema.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/security-and-privacy.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/docs/threat-model.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.csv +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.json +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.jsonl +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.log +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.csv +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.json +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.jsonl +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/sample.log +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/dev_local.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/developer_logs.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/external_ai.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/financial_template.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/healthcare_template.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/local_debug.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/multi_environment.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/prd_external_ai.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/stg_test.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/policies/test_dataset.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/pyproject.toml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/scripts/benchmark/README.md +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/__main__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/constants.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/base.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/custom_regex.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/dates.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/email.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/field_names.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/iban.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/ip_address.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/mac_address.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/network.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/payment_card.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/phone.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/presidio_ner.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/regex_safety.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/registry.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/secrets.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/detectors/ssn.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/errors.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/context.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/finding.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/policy.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/models/report.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/atomic_output.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/paths.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/planner.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/rescan.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/sanitizer.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/stdio.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/pipeline/streams.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/context.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/engine.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/loader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/precedence.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/templates.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/policy/validator.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/py.typed +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/accesslog_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/base.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/csv_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/json_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/jsonl_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/jsonlog_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/line_structure.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/logfmt_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/stacktrace_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/syslog_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/readers/text_reader.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/audit.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/next_steps.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/safe_text.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/serializer.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/reporting/terminal.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/templates/starter_policy.yml +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/date_shift.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/generalize.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/keys.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/pseudonymize.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/redact.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/registry.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/src/ciphergate/transforms/replace.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/conftest.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_api_batch_streams.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_atomic_output.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_cli_e2e.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_domain_templates.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_environment_matrix.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_pipelines.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_stdio.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/regression/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/regression/test_known_cases.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_key_file.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_no_network.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_no_raw_value_leaks.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_path_safety.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_policy_fail_closed.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/security/test_review_findings.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_benchmark.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_ci_supply_chain.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_claude_tooling.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_git_tooling.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/tooling/test_publish_workflow.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/__init__.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_accesslog.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_detectors.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_jsonlog.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_logfmt.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_next_steps.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_engine.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_precedence.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_templates.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_policy_validation.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_presidio_ner.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_properties.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_readers.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_stacktrace.py +0 -0
- {ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/unit/test_syslog.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
32
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
+ ";
|
|
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(
|
|
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,
|
|
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(
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/examples/expected/sample.dev_local_dev.jsonl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ciphergate_cli-0.13.0 → ciphergate_cli-0.14.0}/tests/integration/test_environment_matrix.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|