ciphergate-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,282 @@
1
+ """Run setup and per-record analysis (readers -> detectors -> policy engine).
2
+
3
+ The same ``Analyzer`` is used by inspect, plan, sanitize (both passes) and rescan, so the plan
4
+ predicts exactly the decisions that apply mode executes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass, replace
11
+ from pathlib import Path
12
+
13
+ from ciphergate.config import RuntimeSettings
14
+ from ciphergate.constants import STACK_TRACE_DETECTOR, Action, InputFormat, ReasonCode
15
+ from ciphergate.detectors.base import is_ciphergate_token, make_finding
16
+ from ciphergate.detectors.registry import (
17
+ FIELD_CONTEXT_DETECTORS,
18
+ DetectorSet,
19
+ TextBlockState,
20
+ )
21
+ from ciphergate.models.context import ContextInput, EffectiveContext
22
+ from ciphergate.models.finding import Finding, ScanItem
23
+ from ciphergate.pipeline.paths import validate_input_path
24
+ from ciphergate.policy.context import resolve_context
25
+ from ciphergate.policy.engine import Decision, PolicyEngine
26
+ from ciphergate.policy.precedence import LEVEL_HARD_CONSTRAINT
27
+ from ciphergate.policy.validator import Policy
28
+ from ciphergate.readers import (
29
+ STRUCTURED_LINE_FORMATS,
30
+ ReaderOptions,
31
+ Record,
32
+ detect_format,
33
+ open_reader,
34
+ )
35
+ from ciphergate.readers.base import Reader, normalize_encoding
36
+ from ciphergate.readers.json_reader import JSONReader
37
+ from ciphergate.readers.line_structure import (
38
+ LineStructure,
39
+ region_tokens,
40
+ snap_span,
41
+ token_at,
42
+ )
43
+ from ciphergate.readers.logfmt_reader import field_for_span, parse_fields
44
+ from ciphergate.readers.stacktrace_reader import FRAME, TraceState, classify
45
+
46
+ Evaluated = list[tuple[Finding, Decision]]
47
+
48
+
49
+ def _attribute(finding: Finding, key: str) -> Finding:
50
+ return _attribute_path(finding, key, "/" + key)
51
+
52
+
53
+ def _attribute_path(finding: Finding, key: str, path: str) -> Finding:
54
+ location = replace(finding.location, field_path=path)
55
+ return replace(finding, field_name=key, location=location)
56
+
57
+
58
+ def _with_span(finding: Finding, span: tuple[int, int]) -> Finding:
59
+ return replace(finding, location=replace(finding.location, span=span))
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class RunSetup:
64
+ input_path: Path
65
+ fmt: InputFormat
66
+ policy: Policy
67
+ ctx: EffectiveContext
68
+ runtime: RuntimeSettings
69
+ reader_options: ReaderOptions
70
+ detectors: DetectorSet
71
+ engine: PolicyEngine
72
+
73
+ def new_reader(self, path: Path | None = None) -> Reader:
74
+ return open_reader(path or self.input_path, self.fmt, self.reader_options)
75
+
76
+
77
+ def prepare_run(
78
+ input_path: str | Path,
79
+ policy: Policy,
80
+ context: ContextInput | None,
81
+ runtime: RuntimeSettings | None = None,
82
+ fmt: InputFormat | None = None,
83
+ ) -> RunSetup:
84
+ runtime = runtime or RuntimeSettings()
85
+ ctx = resolve_context(policy, context)
86
+ path = Path(input_path)
87
+ validate_input_path(path, runtime.max_input_bytes)
88
+ detected = detect_format(path, fmt)
89
+ options = ReaderOptions(
90
+ runtime=runtime,
91
+ policy_settings=policy.document.settings,
92
+ encoding=normalize_encoding(runtime.encoding),
93
+ )
94
+ return RunSetup(
95
+ input_path=path,
96
+ fmt=detected,
97
+ policy=policy,
98
+ ctx=ctx,
99
+ runtime=runtime,
100
+ reader_options=options,
101
+ detectors=DetectorSet(policy.document.detectors, policy.document),
102
+ engine=PolicyEngine(policy),
103
+ )
104
+
105
+
106
+ class Analyzer:
107
+ def __init__(self, setup: RunSetup, reader: Reader) -> None:
108
+ self.setup = setup
109
+ self.reader = reader
110
+ self.text_state = TextBlockState()
111
+ self.trace_state = TraceState()
112
+ self._single_json_document = isinstance(reader, JSONReader)
113
+
114
+ def analyze(self, record: Record) -> list[tuple[ScanItem, Evaluated]]:
115
+ return self._analyze_items(self.reader.iter_items(record))
116
+
117
+ def analyze_header(self) -> list[tuple[ScanItem, Evaluated]]:
118
+ """CSV header cells (key items). Must run before ``records()`` is iterated."""
119
+ header_items = getattr(self.reader, "header_items", None)
120
+ if header_items is None:
121
+ return []
122
+ return self._analyze_items(header_items())
123
+
124
+ def _analyze_items(self, items: Iterable[ScanItem]) -> list[tuple[ScanItem, Evaluated]]:
125
+ setup = self.setup
126
+ out: list[tuple[ScanItem, Evaluated]] = []
127
+ for item in items:
128
+ if setup.fmt == InputFormat.TEXT:
129
+ findings = setup.detectors.scan_text_line(item, self.text_state)
130
+ elif setup.fmt == InputFormat.LOGFMT:
131
+ findings = self._scan_logfmt_line(item)
132
+ elif setup.fmt == InputFormat.STACKTRACE:
133
+ findings = self._scan_stacktrace_line(item)
134
+ elif setup.fmt in STRUCTURED_LINE_FORMATS:
135
+ findings = self._scan_structured_line(item, STRUCTURED_LINE_FORMATS[setup.fmt])
136
+ else:
137
+ findings = setup.detectors.scan(item)
138
+ if not findings:
139
+ continue
140
+ evaluated = [
141
+ (f, self._constrain(setup.engine.decide(f, setup.ctx, setup.fmt), item))
142
+ for f in findings
143
+ ]
144
+ out.append((item, evaluated))
145
+ return out
146
+
147
+ def _scan_logfmt_line(self, item: ScanItem) -> list[Finding]:
148
+ """Text-mode scan of the whole line, plus field context from the key=value parse."""
149
+ detectors = self.setup.detectors
150
+ in_key_block = self.text_state.in_private_key_block
151
+ findings = detectors.scan_text_line(item, self.text_state)
152
+ fields = None if in_key_block else parse_fields(item.text or "")
153
+ if not fields:
154
+ return findings
155
+ out: list[Finding] = []
156
+ for finding in findings:
157
+ span = finding.location.span
158
+ field = field_for_span(fields, span) if span is not None else None
159
+ out.append(_attribute(finding, field.key) if field is not None else finding)
160
+ for field in fields:
161
+ if field.end <= field.start:
162
+ continue # empty value: nothing to protect
163
+ key_item = ScanItem(
164
+ item.record_index, item.line, "/" + field.key, field.key, field.value
165
+ )
166
+ for finding in detectors.scan_field_context(key_item):
167
+ location = replace(
168
+ finding.location, field_path="/" + field.key, span=(field.start, field.end)
169
+ )
170
+ out.append(replace(finding, location=location))
171
+ return detectors.post_process(out)
172
+
173
+ def _scan_stacktrace_line(self, item: ScanItem) -> list[Finding]:
174
+ """Text-mode scan of the line, plus stack trace context from the lines before it."""
175
+ in_key_block = self.text_state.in_private_key_block
176
+ findings = self.setup.detectors.scan_text_line(item, self.text_state)
177
+ if in_key_block:
178
+ self.trace_state.reset()
179
+ return findings
180
+ trace = classify(item.text or "", self.trace_state)
181
+ if trace is None:
182
+ return findings
183
+ # Per-line findings are kept as they are and only get the trace path. Frame and source
184
+ # lines also get a finding of their own, so the policy decides what leaves with them.
185
+ trace_item = replace(item, field_path=trace.pointer)
186
+ out = [replace(f, location=replace(f.location, field_path=trace.pointer)) for f in findings]
187
+ if trace.span is not None:
188
+ out.append(
189
+ make_finding(
190
+ trace_item,
191
+ category="proprietary.source",
192
+ detector=STACK_TRACE_DETECTOR,
193
+ confidence=0.9 if trace.role == FRAME else 0.8,
194
+ evidence=("structure_match", f"family:{trace.family}"),
195
+ span=trace.span,
196
+ )
197
+ )
198
+ return out
199
+
200
+ def _scan_structured_line(self, item: ScanItem, structure: LineStructure) -> list[Finding]:
201
+ """Text-mode scan of the whole line, plus field context from the line's parsed fields."""
202
+ detectors = self.setup.detectors
203
+ in_key_block = self.text_state.in_private_key_block
204
+ findings = detectors.scan_text_line(item, self.text_state)
205
+ text = item.text or ""
206
+ max_depth = self.setup.runtime.max_json_depth
207
+ regions = [] if in_key_block else structure.parse(text, max_depth)
208
+ if not regions:
209
+ return findings
210
+ tokens = region_tokens(regions)
211
+ out: list[Finding] = []
212
+ for finding in findings:
213
+ span = finding.location.span
214
+ if span is None:
215
+ out.append(finding)
216
+ continue
217
+ # Never let a replacement cross field syntax; keep the original span if nothing is left.
218
+ for piece in snap_span(regions, *span) or [span]:
219
+ moved = finding if piece == span else _with_span(finding, piece)
220
+ owner = token_at(tokens, *piece)
221
+ if owner is not None and owner.kind != "key" and owner.key is not None:
222
+ moved = _attribute_path(moved, owner.key, owner.pointer)
223
+ out.append(moved)
224
+ for token in tokens:
225
+ if token.kind == "key" or token.key is None or token.end <= token.start:
226
+ continue
227
+ value_item = ScanItem(
228
+ item.record_index, item.line, token.pointer, token.key, token.value
229
+ )
230
+ # A key CipherGate wrote (marker/pseudonym) says nothing about its value: field
231
+ # context from it would flag our own output. Content detectors still run.
232
+ own_key = is_ciphergate_token(token.key)
233
+ if own_key and not token.escaped:
234
+ continue
235
+ # Escaped strings hide content from the raw-text scan: scan the decoded value too.
236
+ found = (
237
+ detectors.scan(value_item)
238
+ if token.escaped
239
+ else detectors.scan_field_context(value_item)
240
+ )
241
+ if token.hint and not own_key:
242
+ # A canonical name (e.g. CEF "suser" -> "source_user_name") adds heuristics on
243
+ # top of the real key; findings keep the real key for policy field_names rules.
244
+ hint_item = replace(value_item, field_name=token.hint)
245
+ found += detectors.scan_field_context(hint_item)
246
+ if own_key:
247
+ found = [f for f in found if f.detector not in FIELD_CONTEXT_DETECTORS]
248
+ for finding in found:
249
+ location = replace(
250
+ finding.location, field_path=token.pointer, span=(token.start, token.end)
251
+ )
252
+ out.append(replace(finding, field_name=token.key, location=location))
253
+ for token in tokens:
254
+ if token.kind == "key" and token.escaped:
255
+ key_item = ScanItem(item.record_index, item.line, token.pointer, None, token.value)
256
+ for finding in detectors.scan(key_item):
257
+ location = replace(finding.location, span=(token.start, token.end))
258
+ out.append(replace(finding, location=location))
259
+ # Each scan above already applied phone suppression; re-running it on the merged list
260
+ # would let whole-value spans hide findings, so only de-duplicate.
261
+ return detectors.dedupe(out)
262
+
263
+ def _constrain(self, decision: Decision, item: ScanItem) -> Decision:
264
+ """Location-dependent applicability (fail closed to review, never silently skip)."""
265
+ single_doc = self._single_json_document and not getattr(
266
+ self.reader, "top_level_array", False
267
+ )
268
+ header_cell = item.record_index < 0
269
+ unsupported = (decision.action == Action.DROP_RECORD and (single_doc or header_cell)) or (
270
+ decision.action == Action.DROP_FIELD
271
+ and self.setup.fmt in (InputFormat.JSON, InputFormat.JSONL)
272
+ and item.field_name is None
273
+ and not item.in_key
274
+ )
275
+ if unsupported:
276
+ return Decision(
277
+ Action.REVIEW,
278
+ ReasonCode.UNSUPPORTED_TRANSFORM,
279
+ LEVEL_HARD_CONSTRAINT,
280
+ overridden_rule_id=decision.rule_id,
281
+ )
282
+ return decision
@@ -0,0 +1,98 @@
1
+ """Input/output path safety checks.
2
+
3
+ * Input must be an existing regular file (symlinks are resolved; devices, FIFOs, sockets and
4
+ directories are rejected) within ``max_input_bytes``.
5
+ * Output must not resolve to the input or any other protected path (realpath + ``samefile``,
6
+ case-normalised on Windows), must not be a symlink or directory, and must not already exist
7
+ unless ``force`` is given.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import os
14
+ import stat
15
+ from pathlib import Path
16
+
17
+ from ciphergate.errors import InputLimitError, OutputWriteError, UnsafePathError
18
+
19
+
20
+ def validate_input_path(path: Path, max_bytes: int) -> Path:
21
+ try:
22
+ st = os.stat(path) # follows symlinks: we check what will actually be read
23
+ except FileNotFoundError:
24
+ raise UnsafePathError("input file does not exist", code="input_not_found") from None
25
+ except OSError:
26
+ raise UnsafePathError("input file cannot be accessed", code="input_unreadable") from None
27
+ if stat.S_ISDIR(st.st_mode):
28
+ raise UnsafePathError(
29
+ "input is a directory; use a *_directory function", code="input_is_directory"
30
+ )
31
+ if not stat.S_ISREG(st.st_mode):
32
+ raise UnsafePathError(
33
+ "input is not a regular file (devices/FIFOs are refused)", code="input_not_regular"
34
+ )
35
+ if st.st_size > max_bytes:
36
+ raise InputLimitError(
37
+ f"input exceeds max_input_bytes ({max_bytes})", code="size_limit_exceeded"
38
+ )
39
+ return Path(os.path.realpath(path))
40
+
41
+
42
+ def _same(a: Path, b: Path) -> bool:
43
+ ra = os.path.normcase(os.path.realpath(a))
44
+ rb = os.path.normcase(os.path.realpath(b))
45
+ if ra == rb:
46
+ return True
47
+ try:
48
+ return a.exists() and b.exists() and os.path.samefile(a, b)
49
+ except OSError:
50
+ return False
51
+
52
+
53
+ def validate_output_path(
54
+ path: Path, *, protected: list[Path], force: bool, label: str = "output"
55
+ ) -> Path:
56
+ for other in protected:
57
+ if _same(path, other):
58
+ raise UnsafePathError(
59
+ f"{label} path refers to the input or another protected file",
60
+ code=f"{label}_aliases_input",
61
+ )
62
+ if path.is_symlink():
63
+ raise UnsafePathError(
64
+ f"{label} path is a symlink; refusing to write through it", code=f"{label}_is_symlink"
65
+ )
66
+ if path.exists():
67
+ if path.is_dir():
68
+ raise UnsafePathError(f"{label} path is a directory", code=f"{label}_is_directory")
69
+ if not stat.S_ISREG(path.stat().st_mode):
70
+ raise UnsafePathError(
71
+ f"{label} path is not a regular file", code=f"{label}_not_regular"
72
+ )
73
+ if not force:
74
+ raise OutputWriteError(
75
+ f"{label} file already exists; use --force to replace it", code=f"{label}_exists"
76
+ )
77
+ parent = path.parent if str(path.parent) else Path(".")
78
+ if parent.exists() and not parent.is_dir():
79
+ raise UnsafePathError(f"{label} parent is not a directory", code=f"{label}_parent_invalid")
80
+ return path
81
+
82
+
83
+ def file_digest(path: Path) -> str:
84
+ h = hashlib.sha256()
85
+ with path.open("rb") as fh:
86
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
87
+ h.update(chunk)
88
+ return h.hexdigest()
89
+
90
+
91
+ def display_path(path: Path | None, mode: str) -> str | None:
92
+ if path is None:
93
+ return None
94
+ if mode == "redacted":
95
+ return "<redacted>" + (path.suffix if path.suffix else "")
96
+ if mode == "full":
97
+ return str(path)
98
+ return path.name
@@ -0,0 +1,192 @@
1
+ """Pass one: analyse the whole input and aggregate a privacy-safe plan.
2
+
3
+ The plan is the single source of truth for whether apply mode may run. ``block`` or ``review``
4
+ decisions, run-level gate issues, or unavailable keys all prevent output publication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from ciphergate.constants import Action, InputFormat
14
+ from ciphergate.errors import InputLimitError
15
+ from ciphergate.models.finding import Finding, ScanItem
16
+ from ciphergate.models.policy import DateShiftParams, PseudonymizeParams
17
+ from ciphergate.models.report import RunStatus
18
+ from ciphergate.pipeline.inspector import Analyzer, RunSetup
19
+ from ciphergate.pipeline.paths import file_digest
20
+ from ciphergate.policy.context import GateIssue, evaluate_gates
21
+ from ciphergate.policy.engine import Decision
22
+ from ciphergate.readers.base import ReadStats
23
+ from ciphergate.reporting.safe_text import safe_field_path
24
+ from ciphergate.transforms.keys import KeyProvider, KeyStatus
25
+
26
+ REDACTED_KEY = "<redacted-key>"
27
+
28
+
29
+ def redact_key_path(path: str) -> str:
30
+ """Hide the last segment of a path whose key/header itself produced a finding."""
31
+ if path.startswith("/"):
32
+ head = path.rsplit("/", 1)[0]
33
+ return safe_field_path(head) + "/" + REDACTED_KEY if head else "/" + REDACTED_KEY
34
+ return REDACTED_KEY
35
+
36
+
37
+ def finding_item(
38
+ finding: Finding, decision: Decision, *, redact_path: bool = False
39
+ ) -> dict[str, Any]:
40
+ loc = finding.location.to_dict()
41
+ if "field_path" in loc:
42
+ path = loc["field_path"]
43
+ loc["field_path"] = redact_key_path(path) if redact_path else safe_field_path(path)
44
+ return {
45
+ "category": finding.category,
46
+ "detector": finding.detector,
47
+ "confidence": finding.confidence,
48
+ "band": finding.band,
49
+ "evidence": list(finding.evidence),
50
+ "value_type": finding.value_type,
51
+ "location": loc,
52
+ "decision": decision.to_dict(),
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class PlanAccumulator:
58
+ max_items: int
59
+ max_findings: int
60
+ total: int = 0
61
+ by_category: Counter[str] = field(default_factory=Counter)
62
+ by_detector: Counter[str] = field(default_factory=Counter)
63
+ by_action: Counter[str] = field(default_factory=Counter)
64
+ by_band: Counter[str] = field(default_factory=Counter)
65
+ by_reason: Counter[str] = field(default_factory=Counter)
66
+ by_rule: Counter[str] = field(default_factory=Counter)
67
+ blocked: int = 0
68
+ review: int = 0
69
+ items: list[dict[str, Any]] = field(default_factory=list)
70
+ truncated: bool = False
71
+ dropped_columns: dict[int, str] = field(default_factory=dict)
72
+ records_to_drop: set[int] = field(default_factory=set)
73
+ malformed_records: int = 0
74
+ key_envs: set[str] = field(default_factory=set)
75
+ # CSV columns whose header cell produced a finding: their label is never reported.
76
+ sensitive_columns: set[int] = field(default_factory=set)
77
+
78
+ def add(self, item: ScanItem, finding: Finding, decision: Decision) -> None:
79
+ self.total += 1
80
+ if self.total > self.max_findings:
81
+ raise InputLimitError(
82
+ f"more than {self.max_findings} findings (max_findings)", code="too_many_findings"
83
+ )
84
+ if item.in_key and item.column_index is not None:
85
+ self.sensitive_columns.add(item.column_index)
86
+ redact = item.in_key or item.column_index in self.sensitive_columns
87
+ action = str(decision.action)
88
+ self.by_category[finding.category] += 1
89
+ self.by_detector[finding.detector] += 1
90
+ self.by_action[action] += 1
91
+ self.by_band[finding.band] += 1
92
+ self.by_reason[str(decision.reason_code)] += 1
93
+ if decision.rule_id:
94
+ self.by_rule[decision.rule_id] += 1
95
+ if decision.action == Action.BLOCK:
96
+ self.blocked += 1
97
+ elif decision.action == Action.REVIEW:
98
+ self.review += 1
99
+ elif decision.action == Action.DROP_FIELD and item.column_index is not None:
100
+ self.dropped_columns[item.column_index] = (
101
+ REDACTED_KEY if redact else safe_field_path(item.field_path)
102
+ )
103
+ elif decision.action == Action.DROP_RECORD:
104
+ self.records_to_drop.add(item.record_index)
105
+ if isinstance(decision.params, PseudonymizeParams | DateShiftParams):
106
+ self.key_envs.add(decision.params.key_env)
107
+ important = decision.action in (Action.BLOCK, Action.REVIEW)
108
+ if len(self.items) < self.max_items:
109
+ self.items.append(finding_item(finding, decision, redact_path=redact))
110
+ elif important:
111
+ # Make sure blocking items are visible even when the item list is full.
112
+ self.items.append(finding_item(finding, decision, redact_path=redact))
113
+ self.truncated = True
114
+ else:
115
+ self.truncated = True
116
+
117
+ def summary(self) -> dict[str, Any]:
118
+ def ordered(counter: Counter[str]) -> dict[str, int]:
119
+ return dict(sorted(counter.items()))
120
+
121
+ return {
122
+ "findings_total": self.total,
123
+ "by_category": ordered(self.by_category),
124
+ "by_detector": ordered(self.by_detector),
125
+ "by_action": ordered(self.by_action),
126
+ "by_confidence_band": ordered(self.by_band),
127
+ "by_reason_code": ordered(self.by_reason),
128
+ "by_rule": ordered(self.by_rule),
129
+ "blocked": self.blocked,
130
+ "review_required": self.review,
131
+ "records_to_drop": len(self.records_to_drop),
132
+ "malformed_records_dropped": self.malformed_records,
133
+ }
134
+
135
+
136
+ @dataclass
137
+ class Analysis:
138
+ setup: RunSetup
139
+ stats: ReadStats
140
+ acc: PlanAccumulator
141
+ gates: list[GateIssue]
142
+ approvals: list[str]
143
+ digest: str
144
+ key_statuses: dict[str, KeyStatus]
145
+ top_level_array: bool
146
+ key_sources: dict[str, str] = field(default_factory=dict)
147
+
148
+ @property
149
+ def status(self) -> RunStatus:
150
+ if self.acc.blocked or any(g.severity == "block" for g in self.gates):
151
+ return RunStatus.BLOCKED
152
+ if self.acc.review or any(g.severity == "review" for g in self.gates):
153
+ return RunStatus.REVIEW_REQUIRED
154
+ if any(s != KeyStatus.AVAILABLE for s in self.key_statuses.values()):
155
+ return RunStatus.CONFIGURATION_REQUIRED
156
+ return RunStatus.READY
157
+
158
+
159
+ def analyze_file(setup: RunSetup, keys: KeyProvider) -> Analysis:
160
+ gates, approvals = evaluate_gates(setup.policy, setup.ctx, setup.fmt)
161
+ digest = file_digest(setup.input_path)
162
+ reader = setup.new_reader()
163
+ analyzer = Analyzer(setup, reader)
164
+ acc = PlanAccumulator(
165
+ max_items=setup.runtime.max_report_items, max_findings=setup.runtime.max_findings
166
+ )
167
+ for item, evaluated in analyzer.analyze_header():
168
+ for finding, decision in evaluated:
169
+ acc.add(item, finding, decision)
170
+ for record in reader.records():
171
+ if record.malformed:
172
+ acc.malformed_records += 1
173
+ continue
174
+ for item, evaluated in analyzer.analyze(record):
175
+ for finding, decision in evaluated:
176
+ acc.add(item, finding, decision)
177
+ key_statuses = {name: keys.status(name) for name in sorted(acc.key_envs)}
178
+ return Analysis(
179
+ setup=setup,
180
+ stats=reader.stats,
181
+ acc=acc,
182
+ gates=gates,
183
+ approvals=approvals,
184
+ digest=digest,
185
+ key_statuses=key_statuses,
186
+ top_level_array=bool(getattr(reader, "top_level_array", False)),
187
+ key_sources={name: keys.source(name) for name in key_statuses},
188
+ )
189
+
190
+
191
+ def is_structured(fmt: InputFormat) -> bool:
192
+ return fmt != InputFormat.TEXT
@@ -0,0 +1,120 @@
1
+ """Residual scanning of (sanitized) output. Read-only; never mutates the scanned file.
2
+
3
+ Each residual finding is classified:
4
+
5
+ * ``allowed`` — the policy decision for it is ``allow``;
6
+ * ``recognized_output`` — the text is a CipherGate marker, a pseudonym token, a configured
7
+ replacement constant, or already has the shape of the configured generalization;
8
+ * ``unverifiable`` — the policy would date-shift it (a shifted date looks like any date);
9
+ * ``unexpected`` — everything else. Unexpected residuals in ``settings.rescan.fail_on_categories``
10
+ (default: high-risk categories) are *gating*; in strict mode they fail the rescan.
11
+
12
+ A clean rescan is a quality gate, not proof that no sensitive data remains.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import Counter
18
+ from collections.abc import Iterator
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from ciphergate.constants import Action
23
+ from ciphergate.detectors.base import is_ciphergate_token
24
+ from ciphergate.models.finding import ScanItem
25
+ from ciphergate.models.policy import GeneralizeParams
26
+ from ciphergate.models.report import RescanSummary
27
+ from ciphergate.pipeline.inspector import Analyzer, Evaluated, RunSetup
28
+ from ciphergate.pipeline.planner import finding_item
29
+ from ciphergate.readers import STRUCTURED_LINE_FORMATS
30
+ from ciphergate.readers.line_structure import SpanCodec, token_at
31
+ from ciphergate.transforms.generalize import matches_generalized_shape
32
+
33
+
34
+ def rescan_path(setup: RunSetup, path: Path, *, strict: bool) -> RescanSummary:
35
+ reader = setup.new_reader(path)
36
+ analyzer = Analyzer(setup, reader)
37
+ policy = setup.policy
38
+ fail_set = policy.rescan_fail_categories(untrusted=setup.ctx.is_untrusted)
39
+ counts: Counter[str] = Counter(
40
+ {"allowed": 0, "recognized_output": 0, "unverifiable": 0, "unexpected": 0}
41
+ )
42
+ unexpected: Counter[str] = Counter()
43
+ gating: Counter[str] = Counter()
44
+ items: list[dict[str, Any]] = []
45
+ truncated = False
46
+ sensitive_columns: set[int] = set()
47
+
48
+ def analyzed() -> Iterator[tuple[ScanItem, Evaluated]]:
49
+ yield from analyzer.analyze_header()
50
+ for record in reader.records():
51
+ if record.malformed:
52
+ counts["malformed_records"] += 1
53
+ continue
54
+ yield from analyzer.analyze(record)
55
+
56
+ for item, evaluated in analyzed():
57
+ if item.in_key and item.column_index is not None and evaluated:
58
+ sensitive_columns.add(item.column_index)
59
+ redact = item.in_key or item.column_index in sensitive_columns
60
+ text = item.text if item.text is not None else ""
61
+ # Structured line values are escaped in the line: compare what the transform wrote.
62
+ structure = STRUCTURED_LINE_FORMATS.get(setup.fmt)
63
+ codec = (
64
+ SpanCodec(structure, structure.parse(text, setup.runtime.max_json_depth))
65
+ if structure is not None
66
+ else None
67
+ )
68
+ for finding, decision in evaluated:
69
+ span = finding.location.span
70
+ piece = text[span[0] : span[1]] if span else text
71
+ whole: str | None = None # the decoded value of the field the finding sits in
72
+ if codec is not None and span is not None:
73
+ decoded = codec.decode(span[0], span[1], piece)
74
+ piece = decoded if isinstance(decoded, str) else piece
75
+ owner = token_at(codec.tokens, span[0], span[1])
76
+ if owner is not None and owner.kind != "key" and isinstance(owner.value, str):
77
+ whole = owner.value
78
+ if decision.action == Action.ALLOW:
79
+ cls = "allowed"
80
+ elif (
81
+ is_ciphergate_token(piece)
82
+ or piece in policy.replacement_values
83
+ # A pattern can match part of an escaped marker (e.g. "[REDACTED:x\]" in a
84
+ # syslog SD value); a field that holds exactly our output is recognised.
85
+ or (whole is not None and is_ciphergate_token(whole))
86
+ or (whole is not None and whole in policy.replacement_values)
87
+ or (
88
+ decision.action == Action.GENERALIZE
89
+ and isinstance(decision.params, GeneralizeParams)
90
+ and matches_generalized_shape(piece, decision.params)
91
+ )
92
+ ):
93
+ cls = "recognized_output"
94
+ elif decision.action == Action.DATE_SHIFT:
95
+ cls = "unverifiable"
96
+ else:
97
+ cls = "unexpected"
98
+ counts[cls] += 1
99
+ if cls != "unexpected":
100
+ continue
101
+ unexpected[finding.category] += 1
102
+ is_gating = finding.category in fail_set
103
+ if is_gating:
104
+ gating[finding.category] += 1
105
+ if len(items) < setup.runtime.max_report_items:
106
+ entry = finding_item(finding, decision, redact_path=redact)
107
+ entry["residual"] = "gating" if is_gating else "non_gating"
108
+ items.append(entry)
109
+ else:
110
+ truncated = True
111
+ return RescanSummary(
112
+ strict=strict,
113
+ failed=strict and sum(gating.values()) > 0,
114
+ fail_on_categories=sorted(fail_set),
115
+ counts=dict(counts),
116
+ unexpected_by_category=dict(sorted(unexpected.items())),
117
+ gating_by_category=dict(sorted(gating.items())),
118
+ items=items,
119
+ items_truncated=truncated,
120
+ )