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