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,392 @@
|
|
|
1
|
+
"""inspect / plan / sanitize / rescan orchestration.
|
|
2
|
+
|
|
3
|
+
Sanitize flow (apply mode):
|
|
4
|
+
|
|
5
|
+
1. validate input and output paths (no aliasing, no accidental overwrite);
|
|
6
|
+
2. pass one: full analysis and plan; stop here unless the plan status is ``ready``;
|
|
7
|
+
3. pass two: re-read, re-decide (same engine) and transform into an ``AtomicOutput`` temp file;
|
|
8
|
+
4. verify pass-two decision counts equal the plan (otherwise internal error, nothing published);
|
|
9
|
+
5. residual rescan of the temp file (when enabled) — gating residuals withhold the output;
|
|
10
|
+
6. verify the input file did not change during processing;
|
|
11
|
+
7. atomically publish. Any failure or interruption removes the temp file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import IO, Any
|
|
20
|
+
|
|
21
|
+
from ciphergate.config import RuntimeSettings
|
|
22
|
+
from ciphergate.constants import LINE_FORMATS, Action, InputFormat
|
|
23
|
+
from ciphergate.detectors.field_names import normalize_field_name
|
|
24
|
+
from ciphergate.errors import InputFormatError, InternalError, TransformError
|
|
25
|
+
from ciphergate.models.context import ContextInput
|
|
26
|
+
from ciphergate.models.report import RunReport, RunStatus
|
|
27
|
+
from ciphergate.pipeline.atomic_output import AtomicOutput
|
|
28
|
+
from ciphergate.pipeline.inspector import Analyzer, Evaluated, RunSetup, prepare_run
|
|
29
|
+
from ciphergate.pipeline.paths import display_path, file_digest, validate_output_path
|
|
30
|
+
from ciphergate.pipeline.planner import Analysis, analyze_file
|
|
31
|
+
from ciphergate.pipeline.rescan import rescan_path
|
|
32
|
+
from ciphergate.policy.validator import Policy
|
|
33
|
+
from ciphergate.readers import STRUCTURED_LINE_FORMATS, Reader, Record, make_writer
|
|
34
|
+
from ciphergate.readers.json_reader import pointer_escape
|
|
35
|
+
from ciphergate.readers.line_structure import (
|
|
36
|
+
FieldRegion,
|
|
37
|
+
SpanCodec,
|
|
38
|
+
region_tokens,
|
|
39
|
+
structure_still_valid,
|
|
40
|
+
)
|
|
41
|
+
from ciphergate.readers.logfmt_reader import parse_fields
|
|
42
|
+
from ciphergate.reporting.audit import build_report
|
|
43
|
+
from ciphergate.transforms.keys import KeyProvider
|
|
44
|
+
from ciphergate.transforms.registry import EntityLookup, Transformer
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SanitizeOptions:
|
|
49
|
+
apply: bool = True
|
|
50
|
+
force: bool = False
|
|
51
|
+
keys: KeyProvider | None = None
|
|
52
|
+
format: InputFormat | None = None
|
|
53
|
+
runtime: RuntimeSettings | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- public operations
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def inspect_file(
|
|
60
|
+
path: str | Path,
|
|
61
|
+
policy: Policy,
|
|
62
|
+
context: ContextInput | None = None,
|
|
63
|
+
*,
|
|
64
|
+
runtime: RuntimeSettings | None = None,
|
|
65
|
+
fmt: InputFormat | None = None,
|
|
66
|
+
keys: KeyProvider | None = None,
|
|
67
|
+
) -> RunReport:
|
|
68
|
+
"""Detect and evaluate; never writes any data file."""
|
|
69
|
+
setup = prepare_run(path, policy, context, runtime, fmt)
|
|
70
|
+
analysis = analyze_file(setup, keys or KeyProvider())
|
|
71
|
+
return build_report("inspect", analysis)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def plan_file(
|
|
75
|
+
path: str | Path,
|
|
76
|
+
policy: Policy,
|
|
77
|
+
context: ContextInput | None = None,
|
|
78
|
+
*,
|
|
79
|
+
runtime: RuntimeSettings | None = None,
|
|
80
|
+
fmt: InputFormat | None = None,
|
|
81
|
+
keys: KeyProvider | None = None,
|
|
82
|
+
) -> RunReport:
|
|
83
|
+
"""Preview the decisions apply mode would execute; never writes any data file."""
|
|
84
|
+
setup = prepare_run(path, policy, context, runtime, fmt)
|
|
85
|
+
analysis = analyze_file(setup, keys or KeyProvider())
|
|
86
|
+
return build_report("plan", analysis, output=_withheld(None, analysis, "plan_only"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def rescan_file(
|
|
90
|
+
path: str | Path,
|
|
91
|
+
policy: Policy,
|
|
92
|
+
context: ContextInput | None = None,
|
|
93
|
+
*,
|
|
94
|
+
strict: bool | None = None,
|
|
95
|
+
runtime: RuntimeSettings | None = None,
|
|
96
|
+
fmt: InputFormat | None = None,
|
|
97
|
+
) -> RunReport:
|
|
98
|
+
"""Report residual findings in an existing (sanitized) file. Read-only."""
|
|
99
|
+
setup = prepare_run(path, policy, context, runtime, fmt)
|
|
100
|
+
effective_strict = policy.document.settings.strict if strict is None else strict
|
|
101
|
+
analysis = analyze_file(setup, KeyProvider({}))
|
|
102
|
+
summary = rescan_path(setup, setup.input_path, strict=effective_strict)
|
|
103
|
+
if file_digest(setup.input_path) != analysis.digest:
|
|
104
|
+
raise InputFormatError("file changed while being rescanned", code="input_changed")
|
|
105
|
+
status = RunStatus.RESCAN_FAILED if summary.failed else RunStatus.PASSED
|
|
106
|
+
warnings = []
|
|
107
|
+
if not summary.failed and summary.counts.get("unexpected", 0):
|
|
108
|
+
warnings.append("unexpected residual findings present (not gating in this mode)")
|
|
109
|
+
analysis.key_statuses = {}
|
|
110
|
+
return build_report("rescan", analysis, status=status, rescan=summary, extra_warnings=warnings)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sanitize_file(
|
|
114
|
+
input_path: str | Path,
|
|
115
|
+
output_path: str | Path,
|
|
116
|
+
policy: Policy,
|
|
117
|
+
context: ContextInput | None = None,
|
|
118
|
+
options: SanitizeOptions | None = None,
|
|
119
|
+
) -> RunReport:
|
|
120
|
+
options = options or SanitizeOptions()
|
|
121
|
+
setup = prepare_run(input_path, policy, context, options.runtime, options.format)
|
|
122
|
+
out_path = Path(output_path)
|
|
123
|
+
validate_output_path(out_path, protected=[setup.input_path], force=options.force)
|
|
124
|
+
keys = options.keys or KeyProvider()
|
|
125
|
+
analysis = analyze_file(setup, keys)
|
|
126
|
+
shown = display_path(out_path, setup.runtime.path_display)
|
|
127
|
+
if not options.apply:
|
|
128
|
+
return build_report(
|
|
129
|
+
"sanitize", analysis, output=_withheld(shown, analysis, "apply_not_set")
|
|
130
|
+
)
|
|
131
|
+
if analysis.status != RunStatus.READY:
|
|
132
|
+
return build_report(
|
|
133
|
+
"sanitize", analysis, output=_withheld(shown, analysis, str(analysis.status))
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
encoding = setup.reader_options.encoding.replace("-sig", "")
|
|
137
|
+
with AtomicOutput(out_path, force=options.force, encoding=encoding) as out:
|
|
138
|
+
changes = _write_transformed(setup, analysis, out.stream, keys)
|
|
139
|
+
out.finish_writing()
|
|
140
|
+
rescan = None
|
|
141
|
+
if policy.document.settings.rescan.enabled:
|
|
142
|
+
if out.temp_path is None:
|
|
143
|
+
raise InternalError("temporary output missing", code="output_temp_missing")
|
|
144
|
+
rescan = rescan_path(setup, out.temp_path, strict=True)
|
|
145
|
+
if rescan.failed:
|
|
146
|
+
out.abort()
|
|
147
|
+
return build_report(
|
|
148
|
+
"sanitize",
|
|
149
|
+
analysis,
|
|
150
|
+
status=RunStatus.RESCAN_FAILED,
|
|
151
|
+
output=_withheld(shown, analysis, "residual_finding"),
|
|
152
|
+
changes=changes,
|
|
153
|
+
rescan=rescan,
|
|
154
|
+
)
|
|
155
|
+
if file_digest(setup.input_path) != analysis.digest:
|
|
156
|
+
raise InputFormatError(
|
|
157
|
+
"input changed during processing; output discarded", code="input_changed"
|
|
158
|
+
)
|
|
159
|
+
out.publish()
|
|
160
|
+
warnings = [] if rescan is not None else ["rescan gate disabled by policy"]
|
|
161
|
+
return build_report(
|
|
162
|
+
"sanitize",
|
|
163
|
+
analysis,
|
|
164
|
+
status=RunStatus.SANITIZED,
|
|
165
|
+
output={"written": True, "path": shown},
|
|
166
|
+
changes=changes,
|
|
167
|
+
rescan=rescan,
|
|
168
|
+
extra_warnings=warnings,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _withheld(shown: str | None, analysis: Analysis, reason: str) -> dict[str, Any]:
|
|
173
|
+
return {"written": False, "path": shown, "withheld_reason": reason}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- pass two
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _write_transformed(
|
|
180
|
+
setup: RunSetup, analysis: Analysis, stream: IO[str], keys: KeyProvider
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
reader = setup.new_reader()
|
|
183
|
+
analyzer = Analyzer(setup, reader)
|
|
184
|
+
transformer = Transformer(setup.policy, keys)
|
|
185
|
+
writer = make_writer(stream, reader, set(analysis.acc.dropped_columns))
|
|
186
|
+
actions: Counter[str] = Counter()
|
|
187
|
+
stats = {
|
|
188
|
+
"records_written": 0,
|
|
189
|
+
"records_dropped": 0,
|
|
190
|
+
"malformed_records_dropped": 0,
|
|
191
|
+
"values_changed": 0,
|
|
192
|
+
"type_changes": 0,
|
|
193
|
+
}
|
|
194
|
+
for _, evaluated in analyzer.analyze_header():
|
|
195
|
+
for _, decision in evaluated:
|
|
196
|
+
actions[str(decision.action)] += 1
|
|
197
|
+
for record in reader.records():
|
|
198
|
+
if record.malformed:
|
|
199
|
+
stats["malformed_records_dropped"] += 1
|
|
200
|
+
continue
|
|
201
|
+
if record.index < 0:
|
|
202
|
+
writer.write_record(record, record.data)
|
|
203
|
+
continue
|
|
204
|
+
analyzed = analyzer.analyze(record)
|
|
205
|
+
for _, evaluated in analyzed:
|
|
206
|
+
for _, decision in evaluated:
|
|
207
|
+
actions[str(decision.action)] += 1
|
|
208
|
+
if any(d.action == Action.DROP_RECORD for _, ev in analyzed for _, d in ev):
|
|
209
|
+
stats["records_dropped"] += 1
|
|
210
|
+
continue
|
|
211
|
+
data = _transform_record(
|
|
212
|
+
setup.fmt, record, analyzed, transformer, stats, reader, setup.runtime.max_json_depth
|
|
213
|
+
)
|
|
214
|
+
writer.write_record(record, data)
|
|
215
|
+
stats["records_written"] += 1
|
|
216
|
+
writer.close()
|
|
217
|
+
if dict(actions) != dict(analysis.acc.by_action):
|
|
218
|
+
raise InternalError(
|
|
219
|
+
"apply-mode decisions differ from the plan; output discarded",
|
|
220
|
+
code="plan_apply_mismatch",
|
|
221
|
+
)
|
|
222
|
+
dropped = [analysis.acc.dropped_columns[i] for i in sorted(analysis.acc.dropped_columns)]
|
|
223
|
+
return {**stats, "dropped_columns": dropped, "schema_changed": bool(dropped)}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _csv_text(value: Any) -> str:
|
|
227
|
+
if value is None:
|
|
228
|
+
return ""
|
|
229
|
+
if isinstance(value, bool):
|
|
230
|
+
return "true" if value else "false"
|
|
231
|
+
return str(value)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _transform_record(
|
|
235
|
+
fmt: InputFormat,
|
|
236
|
+
record: Record,
|
|
237
|
+
analyzed: list[tuple[Any, Evaluated]],
|
|
238
|
+
transformer: Transformer,
|
|
239
|
+
stats: dict[str, int],
|
|
240
|
+
reader: Reader,
|
|
241
|
+
max_depth: int,
|
|
242
|
+
) -> Any:
|
|
243
|
+
def note(changed: bool, type_changed: bool) -> None:
|
|
244
|
+
stats["values_changed"] += int(changed)
|
|
245
|
+
stats["type_changes"] += int(type_changed)
|
|
246
|
+
|
|
247
|
+
if fmt in LINE_FORMATS:
|
|
248
|
+
line = record.data
|
|
249
|
+
lookup: EntityLookup | None = None
|
|
250
|
+
codec: SpanCodec | None = None
|
|
251
|
+
regions: list[FieldRegion] = []
|
|
252
|
+
structure = STRUCTURED_LINE_FORMATS.get(fmt)
|
|
253
|
+
if fmt == InputFormat.LOGFMT:
|
|
254
|
+
lookup = _logfmt_lookup(line)
|
|
255
|
+
elif structure is not None:
|
|
256
|
+
regions = structure.parse(line, max_depth)
|
|
257
|
+
lookup, codec = _structured_lookup(regions), SpanCodec(structure, regions)
|
|
258
|
+
original = line
|
|
259
|
+
for item, evaluated in analyzed:
|
|
260
|
+
outcome = transformer.transform_value(
|
|
261
|
+
line, evaluated, field_path=item.field_path, entity_lookup=lookup, span_codec=codec
|
|
262
|
+
)
|
|
263
|
+
note(outcome.changed, False)
|
|
264
|
+
line = outcome.value
|
|
265
|
+
if (
|
|
266
|
+
structure is not None
|
|
267
|
+
and regions
|
|
268
|
+
and line != original
|
|
269
|
+
and not structure_still_valid(structure, line, regions, max_depth)
|
|
270
|
+
):
|
|
271
|
+
raise TransformError(
|
|
272
|
+
f"rewriting a line would break its {fmt} structure; nothing was published",
|
|
273
|
+
code=f"{fmt}_rewrite_invalid",
|
|
274
|
+
)
|
|
275
|
+
return line
|
|
276
|
+
|
|
277
|
+
if fmt == InputFormat.CSV:
|
|
278
|
+
row = list(record.data)
|
|
279
|
+
lookup = _csv_lookup(record, getattr(reader, "field_names", []))
|
|
280
|
+
for item, evaluated in analyzed:
|
|
281
|
+
outcome = transformer.transform_value(
|
|
282
|
+
row[item.column_index], evaluated, field_path=item.field_path, entity_lookup=lookup
|
|
283
|
+
)
|
|
284
|
+
if not outcome.drop_field:
|
|
285
|
+
row[item.column_index] = _csv_text(outcome.value)
|
|
286
|
+
note(outcome.changed, False)
|
|
287
|
+
return row
|
|
288
|
+
|
|
289
|
+
values: dict[str, Evaluated] = {}
|
|
290
|
+
key_drops: set[str] = set()
|
|
291
|
+
for item, evaluated in analyzed:
|
|
292
|
+
if item.in_key:
|
|
293
|
+
if any(d.action == Action.DROP_FIELD for _, d in evaluated):
|
|
294
|
+
key_drops.add(item.field_path)
|
|
295
|
+
else:
|
|
296
|
+
values[item.field_path] = evaluated
|
|
297
|
+
prefix = f"/{record.index}" if getattr(reader, "top_level_array", False) else ""
|
|
298
|
+
lookup = _json_lookup(record.data)
|
|
299
|
+
|
|
300
|
+
def rebuild(node: Any, pointer: str) -> tuple[Any, bool]:
|
|
301
|
+
if isinstance(node, dict):
|
|
302
|
+
out: dict[str, Any] = {}
|
|
303
|
+
for key, value in node.items():
|
|
304
|
+
child = f"{pointer}/{pointer_escape(key)}"
|
|
305
|
+
if child in key_drops:
|
|
306
|
+
note(True, False)
|
|
307
|
+
continue
|
|
308
|
+
new_value, drop = rebuild(value, child)
|
|
309
|
+
if drop:
|
|
310
|
+
continue
|
|
311
|
+
out[key] = new_value
|
|
312
|
+
return out, False
|
|
313
|
+
if isinstance(node, list):
|
|
314
|
+
items_out = []
|
|
315
|
+
drop_any = False
|
|
316
|
+
for i, value in enumerate(node):
|
|
317
|
+
new_value, drop = rebuild(value, f"{pointer}/{i}")
|
|
318
|
+
drop_any = drop_any or drop
|
|
319
|
+
items_out.append(new_value)
|
|
320
|
+
return items_out, drop_any
|
|
321
|
+
evaluated = values.get(pointer)
|
|
322
|
+
if not evaluated:
|
|
323
|
+
return node, False
|
|
324
|
+
outcome = transformer.transform_value(
|
|
325
|
+
node, evaluated, field_path=pointer, entity_lookup=lookup
|
|
326
|
+
)
|
|
327
|
+
note(outcome.changed, outcome.type_changed)
|
|
328
|
+
if outcome.drop_field:
|
|
329
|
+
return node, True
|
|
330
|
+
return outcome.value, False
|
|
331
|
+
|
|
332
|
+
data, dropped_root = rebuild(record.data, prefix)
|
|
333
|
+
if dropped_root:
|
|
334
|
+
raise TransformError(
|
|
335
|
+
"drop_field cannot remove the root of a record", code="drop_field_unsupported"
|
|
336
|
+
)
|
|
337
|
+
return data
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _csv_lookup(record: Record, field_names: list[str | None]) -> EntityLookup:
|
|
341
|
+
columns: dict[str, Any] = {}
|
|
342
|
+
for i, name in enumerate(field_names):
|
|
343
|
+
if name is not None:
|
|
344
|
+
columns.setdefault(normalize_field_name(name), record.data[i])
|
|
345
|
+
|
|
346
|
+
def lookup(name: str) -> Any:
|
|
347
|
+
return columns.get(normalize_field_name(name))
|
|
348
|
+
|
|
349
|
+
return lookup
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _structured_lookup(regions: list[FieldRegion]) -> EntityLookup:
|
|
353
|
+
values: dict[str, Any] = {}
|
|
354
|
+
for token in region_tokens(regions):
|
|
355
|
+
if token.kind != "key" and token.key is not None:
|
|
356
|
+
values.setdefault(normalize_field_name(token.key), token.value)
|
|
357
|
+
|
|
358
|
+
def lookup(name: str) -> Any:
|
|
359
|
+
return values.get(normalize_field_name(name))
|
|
360
|
+
|
|
361
|
+
return lookup
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _logfmt_lookup(line: str) -> EntityLookup:
|
|
365
|
+
values: dict[str, str] = {}
|
|
366
|
+
for field in parse_fields(line) or []:
|
|
367
|
+
values.setdefault(normalize_field_name(field.key), field.value)
|
|
368
|
+
|
|
369
|
+
def lookup(name: str) -> Any:
|
|
370
|
+
return values.get(normalize_field_name(name))
|
|
371
|
+
|
|
372
|
+
return lookup
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _json_lookup(data: Any) -> EntityLookup:
|
|
376
|
+
def lookup(name: str) -> Any:
|
|
377
|
+
target = normalize_field_name(name)
|
|
378
|
+
stack = [data]
|
|
379
|
+
while stack:
|
|
380
|
+
node = stack.pop(0)
|
|
381
|
+
if isinstance(node, dict):
|
|
382
|
+
for key, value in node.items():
|
|
383
|
+
if (key == name or normalize_field_name(key) == target) and not isinstance(
|
|
384
|
+
value, dict | list
|
|
385
|
+
):
|
|
386
|
+
return value
|
|
387
|
+
stack.extend(v for v in node.values() if isinstance(v, dict | list))
|
|
388
|
+
elif isinstance(node, list):
|
|
389
|
+
stack.extend(v for v in node if isinstance(v, dict | list))
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
return lookup
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Standard input/output support for the CLI (``-`` as INPUT or ``--output``).
|
|
2
|
+
|
|
3
|
+
The pipeline reads its input twice (plan, then apply) and verifies it did not change, and it
|
|
4
|
+
publishes output only after every check passes. A pipe allows neither, so:
|
|
5
|
+
|
|
6
|
+
* stdin is spooled into a private workspace (``mkdtemp``: owner-only on POSIX, the user's temp
|
|
7
|
+
directory ACL on Windows) as a ``0600`` file, capped at ``max_input_bytes`` while reading;
|
|
8
|
+
* ``--output -`` is sanitized into the same workspace and copied to stdout only after the run
|
|
9
|
+
succeeded, so a blocked, review or failed run writes nothing to stdout;
|
|
10
|
+
* the workspace is removed on success, failure and interruption. A hard kill (``SIGKILL``,
|
|
11
|
+
power loss) can leave it behind in the temp directory; the name is ``ciphergate-stdio-*``.
|
|
12
|
+
|
|
13
|
+
The spool holds raw input for the duration of the run, exactly like the input file would.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import IO
|
|
26
|
+
|
|
27
|
+
from ciphergate.constants import InputFormat
|
|
28
|
+
from ciphergate.errors import InputLimitError, OutputWriteError, UsageError
|
|
29
|
+
from ciphergate.reporting.safe_text import escape_control
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("ciphergate")
|
|
32
|
+
|
|
33
|
+
STDIO = "-"
|
|
34
|
+
STDIN_DISPLAY = "<stdin>"
|
|
35
|
+
STDOUT_DISPLAY = "<stdout>"
|
|
36
|
+
_CHUNK = 1024 * 1024
|
|
37
|
+
_SUFFIX = {
|
|
38
|
+
InputFormat.CSV: ".csv",
|
|
39
|
+
InputFormat.JSON: ".json",
|
|
40
|
+
InputFormat.JSONL: ".jsonl",
|
|
41
|
+
InputFormat.TEXT: ".txt",
|
|
42
|
+
InputFormat.LOGFMT: ".log",
|
|
43
|
+
InputFormat.JSONLOG: ".log",
|
|
44
|
+
InputFormat.ACCESSLOG: ".log",
|
|
45
|
+
InputFormat.SYSLOG: ".log",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_stdio(path: Path | None) -> bool:
|
|
50
|
+
return path is not None and str(path) == STDIO
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StdioWorkspace:
|
|
54
|
+
"""Private temporary directory holding the stdin spool and the stdout staging file."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.root: Path | None = None
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> StdioWorkspace:
|
|
60
|
+
try:
|
|
61
|
+
self.root = Path(tempfile.mkdtemp(prefix="ciphergate-stdio-"))
|
|
62
|
+
except OSError:
|
|
63
|
+
raise OutputWriteError(
|
|
64
|
+
"cannot create a private temporary workspace for stdin/stdout",
|
|
65
|
+
code="stdio_workspace_failed",
|
|
66
|
+
) from None
|
|
67
|
+
with contextlib.suppress(OSError):
|
|
68
|
+
os.chmod(self.root, 0o700)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def _dir(self) -> Path:
|
|
72
|
+
if self.root is None:
|
|
73
|
+
raise OutputWriteError("stdio workspace is not open", code="stdio_workspace_failed")
|
|
74
|
+
return self.root
|
|
75
|
+
|
|
76
|
+
def spool_input(
|
|
77
|
+
self, stream: IO[bytes], fmt: InputFormat, max_bytes: int, *, label: str = "stdin"
|
|
78
|
+
) -> Path:
|
|
79
|
+
"""Copy ``stream`` into the workspace; fail (never truncate) beyond ``max_bytes``."""
|
|
80
|
+
path = self._dir() / f"input{_SUFFIX[fmt]}"
|
|
81
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
82
|
+
total = 0
|
|
83
|
+
try:
|
|
84
|
+
with os.fdopen(os.open(path, flags, 0o600), "wb") as out:
|
|
85
|
+
while True:
|
|
86
|
+
chunk = stream.read(_CHUNK)
|
|
87
|
+
if not chunk:
|
|
88
|
+
break
|
|
89
|
+
if not isinstance(chunk, bytes | bytearray | memoryview):
|
|
90
|
+
raise UsageError(
|
|
91
|
+
f"{label} must be a binary stream (open it with 'rb')",
|
|
92
|
+
code="stream_not_binary",
|
|
93
|
+
)
|
|
94
|
+
total += len(chunk)
|
|
95
|
+
if total > max_bytes:
|
|
96
|
+
raise InputLimitError(
|
|
97
|
+
f"{label} exceeds max_input_bytes ({max_bytes})",
|
|
98
|
+
code="size_limit_exceeded",
|
|
99
|
+
)
|
|
100
|
+
out.write(chunk)
|
|
101
|
+
except OSError:
|
|
102
|
+
raise OutputWriteError(
|
|
103
|
+
f"cannot spool {label} into the temporary workspace", code="stdin_spool_failed"
|
|
104
|
+
) from None
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
def output_path(self) -> Path:
|
|
108
|
+
return self._dir() / "sanitized.out"
|
|
109
|
+
|
|
110
|
+
def __exit__(
|
|
111
|
+
self,
|
|
112
|
+
exc_type: type[BaseException] | None,
|
|
113
|
+
exc: BaseException | None,
|
|
114
|
+
tb: TracebackType | None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if self.root is None:
|
|
117
|
+
return
|
|
118
|
+
shutil.rmtree(self.root, ignore_errors=True)
|
|
119
|
+
if self.root.exists():
|
|
120
|
+
logger.warning(
|
|
121
|
+
"could not remove temporary stdin/stdout workspace %s; delete it manually",
|
|
122
|
+
escape_control(str(self.root)),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require_stdin_format(fmt: InputFormat | None, stdin: IO[bytes]) -> InputFormat:
|
|
127
|
+
if fmt is None:
|
|
128
|
+
raise UsageError(
|
|
129
|
+
"reading stdin ('-') requires --format " + "|".join(InputFormat),
|
|
130
|
+
code="format_required",
|
|
131
|
+
)
|
|
132
|
+
if stdin.isatty():
|
|
133
|
+
raise UsageError(
|
|
134
|
+
"INPUT is '-' but stdin is a terminal; pipe or redirect the data in",
|
|
135
|
+
code="stdin_is_terminal",
|
|
136
|
+
)
|
|
137
|
+
return fmt
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def copy_to_stdout(path: Path, stdout: IO[bytes]) -> None:
|
|
141
|
+
"""Write the published staging file to stdout. A closed pipe is an output error.
|
|
142
|
+
|
|
143
|
+
Once copying starts, data may already be downstream, so an interruption here must not be
|
|
144
|
+
reported as "no output was published" (the generic Ctrl-C message): it is an output error.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
with path.open("rb") as fh:
|
|
148
|
+
shutil.copyfileobj(fh, stdout, _CHUNK)
|
|
149
|
+
stdout.flush()
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
raise OutputWriteError(
|
|
152
|
+
"interrupted while writing to stdout; the output received downstream is incomplete",
|
|
153
|
+
code="stdout_interrupted",
|
|
154
|
+
) from None
|
|
155
|
+
except BrokenPipeError:
|
|
156
|
+
silence_stdout()
|
|
157
|
+
raise OutputWriteError(
|
|
158
|
+
"stdout was closed before the sanitized output was fully written; "
|
|
159
|
+
"the output received downstream is incomplete",
|
|
160
|
+
code="stdout_broken_pipe",
|
|
161
|
+
) from None
|
|
162
|
+
except OSError:
|
|
163
|
+
raise OutputWriteError(
|
|
164
|
+
"failed to write the output to stdout", code="stdout_failed"
|
|
165
|
+
) from None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def silence_stdout() -> None:
|
|
169
|
+
"""Point fd 1 at devnull so the interpreter's exit-time flush cannot raise again."""
|
|
170
|
+
with contextlib.suppress(OSError, ValueError, AttributeError):
|
|
171
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
172
|
+
os.dup2(devnull, 1)
|
|
173
|
+
os.close(devnull)
|