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.
- ciphergate/__init__.py +95 -0
- ciphergate/__main__.py +6 -0
- ciphergate/cli.py +832 -0
- ciphergate/config.py +50 -0
- ciphergate/constants.py +274 -0
- ciphergate/detectors/__init__.py +5 -0
- ciphergate/detectors/base.py +53 -0
- ciphergate/detectors/custom_regex.py +37 -0
- ciphergate/detectors/dates.py +92 -0
- ciphergate/detectors/email.py +56 -0
- ciphergate/detectors/field_names.py +220 -0
- ciphergate/detectors/ip_address.py +85 -0
- ciphergate/detectors/network.py +42 -0
- ciphergate/detectors/payment_card.py +77 -0
- ciphergate/detectors/phone.py +67 -0
- ciphergate/detectors/presidio_ner.py +112 -0
- ciphergate/detectors/regex_safety.py +40 -0
- ciphergate/detectors/registry.py +194 -0
- ciphergate/detectors/secrets.py +130 -0
- ciphergate/errors.py +119 -0
- ciphergate/models/__init__.py +1 -0
- ciphergate/models/context.py +69 -0
- ciphergate/models/finding.py +108 -0
- ciphergate/models/policy.py +402 -0
- ciphergate/models/report.py +115 -0
- ciphergate/pipeline/__init__.py +1 -0
- ciphergate/pipeline/atomic_output.py +143 -0
- ciphergate/pipeline/batch.py +533 -0
- ciphergate/pipeline/inspector.py +282 -0
- ciphergate/pipeline/paths.py +98 -0
- ciphergate/pipeline/planner.py +192 -0
- ciphergate/pipeline/rescan.py +120 -0
- ciphergate/pipeline/sanitizer.py +392 -0
- ciphergate/pipeline/stdio.py +173 -0
- ciphergate/pipeline/streams.py +152 -0
- ciphergate/policy/__init__.py +26 -0
- ciphergate/policy/context.py +203 -0
- ciphergate/policy/engine.py +194 -0
- ciphergate/policy/loader.py +116 -0
- ciphergate/policy/precedence.py +149 -0
- ciphergate/policy/validator.py +632 -0
- ciphergate/py.typed +0 -0
- ciphergate/readers/__init__.py +113 -0
- ciphergate/readers/accesslog_reader.py +244 -0
- ciphergate/readers/base.py +131 -0
- ciphergate/readers/csv_reader.py +241 -0
- ciphergate/readers/json_reader.py +184 -0
- ciphergate/readers/jsonl_reader.py +97 -0
- ciphergate/readers/jsonlog_reader.py +178 -0
- ciphergate/readers/line_structure.py +125 -0
- ciphergate/readers/logfmt_reader.py +100 -0
- ciphergate/readers/stacktrace_reader.py +103 -0
- ciphergate/readers/syslog_reader.py +309 -0
- ciphergate/readers/text_reader.py +59 -0
- ciphergate/reporting/__init__.py +1 -0
- ciphergate/reporting/audit.py +85 -0
- ciphergate/reporting/safe_text.py +71 -0
- ciphergate/reporting/serializer.py +44 -0
- ciphergate/reporting/terminal.py +147 -0
- ciphergate/templates/starter_policy.yml +77 -0
- ciphergate/transforms/__init__.py +6 -0
- ciphergate/transforms/date_shift.py +79 -0
- ciphergate/transforms/generalize.py +107 -0
- ciphergate/transforms/keys.py +177 -0
- ciphergate/transforms/pseudonymize.py +113 -0
- ciphergate/transforms/redact.py +9 -0
- ciphergate/transforms/registry.py +201 -0
- ciphergate/transforms/replace.py +16 -0
- ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
- ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
- ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
- ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
- ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
)
|