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,533 @@
|
|
|
1
|
+
"""Directory (batch) processing: one run over every supported file below a directory.
|
|
2
|
+
|
|
3
|
+
Safety contract:
|
|
4
|
+
|
|
5
|
+
* Discovery never follows symlinks, junctions or other reparse points, and never reads special
|
|
6
|
+
files (devices, FIFOs, sockets); each skipped entry is reported with a reason. Subdirectories
|
|
7
|
+
are entered only with ``recursive``; nesting is bounded by ``MAX_BATCH_DEPTH`` and the number
|
|
8
|
+
of files by ``RuntimeSettings.max_batch_files`` (exceeding either fails the whole run).
|
|
9
|
+
* Files are processed in sorted relative-path order, each exactly as the single-file command
|
|
10
|
+
would process it. A failing file does not stop the others, so the report is complete.
|
|
11
|
+
* ``sanitize`` builds the whole output tree in a private staging directory next to the output
|
|
12
|
+
directory and publishes it with one rename only when **every** file was sanitized. Otherwise
|
|
13
|
+
the staging tree is deleted and no output directory appears. The output directory must not
|
|
14
|
+
exist yet and must not be inside the input directory.
|
|
15
|
+
* The batch exit code is that of the first failing file in processing order (0 if none).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import stat
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ciphergate import __version__
|
|
33
|
+
from ciphergate.config import RuntimeSettings
|
|
34
|
+
from ciphergate.constants import DISCLAIMER, ExitCode, InputFormat
|
|
35
|
+
from ciphergate.errors import (
|
|
36
|
+
CipherGateError,
|
|
37
|
+
InputFormatError,
|
|
38
|
+
InputLimitError,
|
|
39
|
+
OutputWriteError,
|
|
40
|
+
UnsafePathError,
|
|
41
|
+
UsageError,
|
|
42
|
+
)
|
|
43
|
+
from ciphergate.models.context import ContextInput
|
|
44
|
+
from ciphergate.models.report import RunReport
|
|
45
|
+
from ciphergate.pipeline.sanitizer import (
|
|
46
|
+
SanitizeOptions,
|
|
47
|
+
inspect_file,
|
|
48
|
+
plan_file,
|
|
49
|
+
rescan_file,
|
|
50
|
+
sanitize_file,
|
|
51
|
+
)
|
|
52
|
+
from ciphergate.policy.validator import Policy
|
|
53
|
+
from ciphergate.readers import EXTENSION_FORMATS
|
|
54
|
+
from ciphergate.reporting.audit import new_run_id, utc_now
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger("ciphergate")
|
|
57
|
+
|
|
58
|
+
BATCH_REPORT_SCHEMA = "ciphergate.batch-report/v1"
|
|
59
|
+
MAX_BATCH_DEPTH = 32
|
|
60
|
+
_REPARSE_POINT = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT (symlinks, junctions, mount points)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Discovery:
|
|
65
|
+
root: Path
|
|
66
|
+
files: list[Path] # relative to root, sorted
|
|
67
|
+
skipped: list[tuple[Path, str]] # (relative path, reason code)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class FileResult:
|
|
72
|
+
relative: Path
|
|
73
|
+
display: str
|
|
74
|
+
exit_code: int
|
|
75
|
+
report: RunReport | None = None
|
|
76
|
+
error_code: str | None = None
|
|
77
|
+
error_message: str | None = None
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
entry: dict[str, Any] = {"path": self.display, "exit_code": self.exit_code}
|
|
81
|
+
if self.report is not None:
|
|
82
|
+
entry["status"] = str(self.report.status)
|
|
83
|
+
entry["report"] = self.report.to_dict()
|
|
84
|
+
else:
|
|
85
|
+
entry["status"] = "error"
|
|
86
|
+
entry["error"] = {"code": self.error_code, "message": self.error_message}
|
|
87
|
+
return entry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class BatchReport:
|
|
92
|
+
command: str
|
|
93
|
+
input_display: str
|
|
94
|
+
recursive: bool
|
|
95
|
+
files: list[FileResult]
|
|
96
|
+
skipped: list[dict[str, str]]
|
|
97
|
+
output: dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
warnings: list[str] = field(default_factory=list)
|
|
99
|
+
run_id: str = field(default_factory=new_run_id)
|
|
100
|
+
timestamp: str = field(default_factory=utc_now)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def exit_code(self) -> int:
|
|
104
|
+
for result in self.files:
|
|
105
|
+
if result.exit_code != 0:
|
|
106
|
+
return result.exit_code
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def status(self) -> str:
|
|
111
|
+
return "completed" if self.exit_code == 0 else "failed"
|
|
112
|
+
|
|
113
|
+
def summary(self) -> dict[str, Any]:
|
|
114
|
+
by_status: dict[str, int] = {}
|
|
115
|
+
findings = blocked = review = 0
|
|
116
|
+
for result in self.files:
|
|
117
|
+
key = str(result.report.status) if result.report is not None else "error"
|
|
118
|
+
by_status[key] = by_status.get(key, 0) + 1
|
|
119
|
+
if result.report is not None:
|
|
120
|
+
findings += int(result.report.summary.get("findings_total", 0))
|
|
121
|
+
blocked += int(result.report.summary.get("blocked", 0))
|
|
122
|
+
review += int(result.report.summary.get("review_required", 0))
|
|
123
|
+
return {
|
|
124
|
+
"files_total": len(self.files),
|
|
125
|
+
"files_ok": sum(1 for r in self.files if r.exit_code == 0),
|
|
126
|
+
"files_failed": sum(1 for r in self.files if r.exit_code != 0),
|
|
127
|
+
"files_skipped": len(self.skipped),
|
|
128
|
+
"by_status": dict(sorted(by_status.items())),
|
|
129
|
+
"findings_total": findings,
|
|
130
|
+
"blocked": blocked,
|
|
131
|
+
"review_required": review,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def to_dict(self) -> dict[str, Any]:
|
|
135
|
+
first = next((r.report for r in self.files if r.report is not None), None)
|
|
136
|
+
return {
|
|
137
|
+
"schema": BATCH_REPORT_SCHEMA,
|
|
138
|
+
"command": self.command,
|
|
139
|
+
"status": self.status,
|
|
140
|
+
"exit_code": self.exit_code,
|
|
141
|
+
"run_id": self.run_id,
|
|
142
|
+
"timestamp": self.timestamp,
|
|
143
|
+
"ciphergate_version": __version__,
|
|
144
|
+
"input": {"path": self.input_display, "recursive": self.recursive},
|
|
145
|
+
"policy": first.policy if first is not None else None,
|
|
146
|
+
"context": first.context if first is not None else None,
|
|
147
|
+
"summary": self.summary(),
|
|
148
|
+
"files": [r.to_dict() for r in self.files],
|
|
149
|
+
"skipped": self.skipped,
|
|
150
|
+
"output": self.output,
|
|
151
|
+
"warnings": self.warnings,
|
|
152
|
+
"disclaimer": DISCLAIMER,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- discovery
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_link(entry: os.DirEntry[str]) -> bool:
|
|
160
|
+
if entry.is_symlink():
|
|
161
|
+
return True
|
|
162
|
+
is_junction = getattr(entry, "is_junction", None)
|
|
163
|
+
if is_junction is not None and is_junction():
|
|
164
|
+
return True
|
|
165
|
+
if sys.platform == "win32":
|
|
166
|
+
attrs = getattr(entry.stat(follow_symlinks=False), "st_file_attributes", 0)
|
|
167
|
+
return bool(attrs & _REPARSE_POINT)
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def discover(root: Path, *, recursive: bool, fmt: InputFormat | None, max_files: int) -> Discovery:
|
|
172
|
+
"""List the files a batch run will process. Raises on limits; never follows links."""
|
|
173
|
+
try:
|
|
174
|
+
st = os.lstat(root)
|
|
175
|
+
except OSError:
|
|
176
|
+
raise UnsafePathError(
|
|
177
|
+
"input directory cannot be accessed", code="input_unreadable"
|
|
178
|
+
) from None
|
|
179
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
180
|
+
raise UnsafePathError(
|
|
181
|
+
"batch input must be a real directory, not a symlink", code="input_not_directory"
|
|
182
|
+
)
|
|
183
|
+
files: list[Path] = []
|
|
184
|
+
skipped: list[tuple[Path, str]] = []
|
|
185
|
+
|
|
186
|
+
def walk(rel_dir: Path, depth: int) -> None:
|
|
187
|
+
try:
|
|
188
|
+
with os.scandir(root / rel_dir) as it:
|
|
189
|
+
entries = sorted(it, key=lambda e: e.name)
|
|
190
|
+
except OSError:
|
|
191
|
+
skipped.append((rel_dir, "directory_unreadable"))
|
|
192
|
+
return
|
|
193
|
+
for entry in entries:
|
|
194
|
+
rel = rel_dir / entry.name
|
|
195
|
+
try:
|
|
196
|
+
if _is_link(entry):
|
|
197
|
+
skipped.append((rel, "symlink_not_followed"))
|
|
198
|
+
elif entry.is_dir(follow_symlinks=False):
|
|
199
|
+
if not recursive:
|
|
200
|
+
skipped.append((rel, "directory_not_recursed"))
|
|
201
|
+
elif depth >= MAX_BATCH_DEPTH:
|
|
202
|
+
raise InputLimitError(
|
|
203
|
+
f"directory nesting exceeds {MAX_BATCH_DEPTH} levels",
|
|
204
|
+
code="batch_depth_exceeded",
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
walk(rel, depth + 1)
|
|
208
|
+
elif not entry.is_file(follow_symlinks=False):
|
|
209
|
+
skipped.append((rel, "not_regular_file"))
|
|
210
|
+
elif fmt is None and rel.suffix.lower() not in EXTENSION_FORMATS:
|
|
211
|
+
skipped.append((rel, "unsupported_extension"))
|
|
212
|
+
else:
|
|
213
|
+
files.append(rel)
|
|
214
|
+
if len(files) > max_files:
|
|
215
|
+
raise InputLimitError(
|
|
216
|
+
f"directory has more than max_batch_files ({max_files}) files",
|
|
217
|
+
code="batch_file_limit_exceeded",
|
|
218
|
+
)
|
|
219
|
+
except OSError:
|
|
220
|
+
skipped.append((rel, "entry_unreadable"))
|
|
221
|
+
|
|
222
|
+
walk(Path(), 0)
|
|
223
|
+
return Discovery(root, sorted(files, key=lambda p: p.as_posix()), skipped)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def display_relative(rel: Path, index: int, root: Path, mode: str) -> str:
|
|
227
|
+
if mode == "redacted":
|
|
228
|
+
return f"<redacted:{index:04d}>{rel.suffix}"
|
|
229
|
+
if mode == "full":
|
|
230
|
+
return str(root / rel)
|
|
231
|
+
return rel.as_posix()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _skipped(discovery: Discovery, mode: str) -> list[dict[str, str]]:
|
|
235
|
+
return [
|
|
236
|
+
{"path": display_relative(rel, i, discovery.root, mode), "reason": reason}
|
|
237
|
+
for i, (rel, reason) in enumerate(discovery.skipped, start=1)
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _still_regular(path: Path) -> bool:
|
|
242
|
+
"""Re-check just before use: the entry must still be a regular file, not a link."""
|
|
243
|
+
try:
|
|
244
|
+
st = os.lstat(path)
|
|
245
|
+
except OSError:
|
|
246
|
+
return False
|
|
247
|
+
return stat.S_ISREG(st.st_mode) and not getattr(st, "st_file_attributes", 0) & _REPARSE_POINT
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _run_each(
|
|
251
|
+
discovery: Discovery,
|
|
252
|
+
mode: str,
|
|
253
|
+
run: Callable[[Path, Path], RunReport],
|
|
254
|
+
) -> list[FileResult]:
|
|
255
|
+
results: list[FileResult] = []
|
|
256
|
+
for index, rel in enumerate(discovery.files, start=1):
|
|
257
|
+
shown = display_relative(rel, index, discovery.root, mode)
|
|
258
|
+
path = discovery.root / rel
|
|
259
|
+
if not _still_regular(path):
|
|
260
|
+
results.append(
|
|
261
|
+
FileResult(
|
|
262
|
+
rel,
|
|
263
|
+
shown,
|
|
264
|
+
int(ExitCode.OUTPUT_ERROR),
|
|
265
|
+
error_code="input_changed_type",
|
|
266
|
+
error_message="entry is no longer a regular file; not processed",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
report = run(path, rel)
|
|
272
|
+
except CipherGateError as exc:
|
|
273
|
+
results.append(FileResult(rel, shown, int(exc.exit_code), None, exc.code, exc.message))
|
|
274
|
+
continue
|
|
275
|
+
report.input["path"] = shown
|
|
276
|
+
results.append(FileResult(rel, shown, report.exit_code, report))
|
|
277
|
+
return results
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _require_files(discovery: Discovery) -> None:
|
|
281
|
+
if not discovery.files:
|
|
282
|
+
raise InputFormatError(
|
|
283
|
+
"no supported input files found in the directory (see --format, --recursive)",
|
|
284
|
+
code="batch_empty",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --------------------------------------------------------------------------- operations
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def analyze_directory(
|
|
292
|
+
command: str,
|
|
293
|
+
root: str | Path,
|
|
294
|
+
policy: Policy,
|
|
295
|
+
context: ContextInput | None,
|
|
296
|
+
*,
|
|
297
|
+
recursive: bool,
|
|
298
|
+
runtime: RuntimeSettings,
|
|
299
|
+
fmt: InputFormat | None = None,
|
|
300
|
+
strict: bool | None = None,
|
|
301
|
+
) -> BatchReport:
|
|
302
|
+
"""Batch ``inspect`` / ``plan`` / ``rescan``. Read-only, like the single-file commands."""
|
|
303
|
+
root = Path(root)
|
|
304
|
+
discovery = discover(root, recursive=recursive, fmt=fmt, max_files=runtime.max_batch_files)
|
|
305
|
+
_require_files(discovery)
|
|
306
|
+
|
|
307
|
+
def run(path: Path, rel: Path) -> RunReport:
|
|
308
|
+
if command == "inspect":
|
|
309
|
+
return inspect_file(path, policy, context, runtime=runtime, fmt=fmt)
|
|
310
|
+
if command == "plan":
|
|
311
|
+
return plan_file(path, policy, context, runtime=runtime, fmt=fmt)
|
|
312
|
+
return rescan_file(path, policy, context, strict=strict, runtime=runtime, fmt=fmt)
|
|
313
|
+
|
|
314
|
+
mode = runtime.path_display
|
|
315
|
+
return BatchReport(
|
|
316
|
+
command=command,
|
|
317
|
+
input_display=display_relative(Path(root.name), 0, root.parent, mode)
|
|
318
|
+
if mode != "redacted"
|
|
319
|
+
else "<redacted>",
|
|
320
|
+
recursive=recursive,
|
|
321
|
+
files=_run_each(discovery, mode, run),
|
|
322
|
+
skipped=_skipped(discovery, mode),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def inspect_directory(
|
|
327
|
+
root: str | Path,
|
|
328
|
+
policy: Policy,
|
|
329
|
+
context: ContextInput | None = None,
|
|
330
|
+
*,
|
|
331
|
+
recursive: bool = False,
|
|
332
|
+
runtime: RuntimeSettings | None = None,
|
|
333
|
+
fmt: InputFormat | None = None,
|
|
334
|
+
) -> BatchReport:
|
|
335
|
+
"""Inspect every supported file below ``root``. Never writes any data file."""
|
|
336
|
+
return analyze_directory(
|
|
337
|
+
"inspect", root, policy, context, recursive=recursive,
|
|
338
|
+
runtime=runtime or RuntimeSettings(), fmt=fmt,
|
|
339
|
+
) # fmt: skip
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def plan_directory(
|
|
343
|
+
root: str | Path,
|
|
344
|
+
policy: Policy,
|
|
345
|
+
context: ContextInput | None = None,
|
|
346
|
+
*,
|
|
347
|
+
recursive: bool = False,
|
|
348
|
+
runtime: RuntimeSettings | None = None,
|
|
349
|
+
fmt: InputFormat | None = None,
|
|
350
|
+
) -> BatchReport:
|
|
351
|
+
"""Preview sanitize decisions for every supported file below ``root``."""
|
|
352
|
+
return analyze_directory(
|
|
353
|
+
"plan", root, policy, context, recursive=recursive,
|
|
354
|
+
runtime=runtime or RuntimeSettings(), fmt=fmt,
|
|
355
|
+
) # fmt: skip
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def rescan_directory(
|
|
359
|
+
root: str | Path,
|
|
360
|
+
policy: Policy,
|
|
361
|
+
context: ContextInput | None = None,
|
|
362
|
+
*,
|
|
363
|
+
recursive: bool = False,
|
|
364
|
+
strict: bool | None = None,
|
|
365
|
+
runtime: RuntimeSettings | None = None,
|
|
366
|
+
fmt: InputFormat | None = None,
|
|
367
|
+
) -> BatchReport:
|
|
368
|
+
"""Report residual findings in every supported file below ``root``. Read-only."""
|
|
369
|
+
return analyze_directory(
|
|
370
|
+
"rescan", root, policy, context, recursive=recursive,
|
|
371
|
+
runtime=runtime or RuntimeSettings(), fmt=fmt, strict=strict,
|
|
372
|
+
) # fmt: skip
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def is_inside(path: Path, directory: Path) -> bool:
|
|
376
|
+
"""True if ``path`` resolves to ``directory`` or somewhere below it.
|
|
377
|
+
|
|
378
|
+
Compares file identity, not just spelling: on case-insensitive filesystems (macOS APFS,
|
|
379
|
+
Windows) ``IN/out`` is inside ``in`` even though ``normcase`` is a no-op on POSIX.
|
|
380
|
+
"""
|
|
381
|
+
real_dir = os.path.normcase(os.path.realpath(directory))
|
|
382
|
+
real = os.path.normcase(os.path.realpath(path))
|
|
383
|
+
if real == real_dir or real.startswith(real_dir.rstrip(os.sep) + os.sep):
|
|
384
|
+
return True
|
|
385
|
+
try:
|
|
386
|
+
dir_stat = os.stat(directory)
|
|
387
|
+
except OSError:
|
|
388
|
+
return False
|
|
389
|
+
current = Path(os.path.realpath(path))
|
|
390
|
+
for candidate in (current, *current.parents):
|
|
391
|
+
try:
|
|
392
|
+
if os.path.samestat(os.stat(candidate), dir_stat):
|
|
393
|
+
return True
|
|
394
|
+
except OSError:
|
|
395
|
+
continue # not created yet
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def validate_output_directory(root: Path, output: Path) -> None:
|
|
400
|
+
if os.path.lexists(output):
|
|
401
|
+
raise OutputWriteError(
|
|
402
|
+
"output directory already exists; choose a new path (--force does not replace "
|
|
403
|
+
"directories)",
|
|
404
|
+
code="output_exists",
|
|
405
|
+
)
|
|
406
|
+
if is_inside(output, root):
|
|
407
|
+
raise UnsafePathError(
|
|
408
|
+
"output directory must not be inside the input directory",
|
|
409
|
+
code="output_inside_input",
|
|
410
|
+
)
|
|
411
|
+
parent = output.parent if str(output.parent) else Path(".")
|
|
412
|
+
if parent.exists() and not parent.is_dir():
|
|
413
|
+
raise UnsafePathError("output parent is not a directory", code="output_parent_invalid")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def sanitize_directory(
|
|
417
|
+
root: str | Path,
|
|
418
|
+
output: str | Path,
|
|
419
|
+
policy: Policy,
|
|
420
|
+
context: ContextInput | None = None,
|
|
421
|
+
options: SanitizeOptions | None = None,
|
|
422
|
+
*,
|
|
423
|
+
recursive: bool = False,
|
|
424
|
+
) -> BatchReport:
|
|
425
|
+
"""Sanitize every file into a new OUTPUT directory, published only if all files pass."""
|
|
426
|
+
root, output = Path(root), Path(output)
|
|
427
|
+
options = options or SanitizeOptions()
|
|
428
|
+
if options.force:
|
|
429
|
+
raise UsageError(
|
|
430
|
+
"force cannot replace an output directory; remove it or choose a new path",
|
|
431
|
+
code="force_unsupported_for_directory",
|
|
432
|
+
)
|
|
433
|
+
runtime = options.runtime or RuntimeSettings()
|
|
434
|
+
validate_output_directory(root, output)
|
|
435
|
+
discovery = discover(
|
|
436
|
+
root, recursive=recursive, fmt=options.format, max_files=runtime.max_batch_files
|
|
437
|
+
)
|
|
438
|
+
_require_files(discovery)
|
|
439
|
+
mode = runtime.path_display
|
|
440
|
+
shown_root = (
|
|
441
|
+
"<redacted>"
|
|
442
|
+
if mode == "redacted"
|
|
443
|
+
else display_relative(Path(root.name), 0, root.parent, mode)
|
|
444
|
+
)
|
|
445
|
+
shown_out = (
|
|
446
|
+
"<redacted>"
|
|
447
|
+
if mode == "redacted"
|
|
448
|
+
else display_relative(Path(output.name), 0, output.parent, mode)
|
|
449
|
+
)
|
|
450
|
+
file_options = SanitizeOptions(
|
|
451
|
+
apply=options.apply,
|
|
452
|
+
force=False,
|
|
453
|
+
keys=options.keys,
|
|
454
|
+
format=options.format,
|
|
455
|
+
runtime=runtime,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if not options.apply:
|
|
459
|
+
# Dry run: plan every file; the output paths are never created.
|
|
460
|
+
results = _run_each(
|
|
461
|
+
discovery,
|
|
462
|
+
mode,
|
|
463
|
+
lambda path, rel: sanitize_file(path, output / rel, policy, context, file_options),
|
|
464
|
+
)
|
|
465
|
+
for result in results:
|
|
466
|
+
if result.report is not None:
|
|
467
|
+
result.report.output["path"] = f"{shown_out}/{result.display}"
|
|
468
|
+
return BatchReport(
|
|
469
|
+
"sanitize",
|
|
470
|
+
shown_root,
|
|
471
|
+
recursive,
|
|
472
|
+
results,
|
|
473
|
+
_skipped(discovery, mode),
|
|
474
|
+
output={"written": False, "path": shown_out, "withheld_reason": "apply_not_set"},
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
parent = output.parent if str(output.parent) else Path(".")
|
|
478
|
+
try:
|
|
479
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
480
|
+
staging = Path(tempfile.mkdtemp(prefix=".ciphergate-batch-", dir=parent))
|
|
481
|
+
except OSError:
|
|
482
|
+
raise OutputWriteError(
|
|
483
|
+
"cannot create a staging directory next to the output", code="output_temp_failed"
|
|
484
|
+
) from None
|
|
485
|
+
published = False
|
|
486
|
+
try:
|
|
487
|
+
with contextlib.suppress(OSError):
|
|
488
|
+
os.chmod(staging, 0o700)
|
|
489
|
+
results = _run_each(
|
|
490
|
+
discovery,
|
|
491
|
+
mode,
|
|
492
|
+
lambda path, rel: sanitize_file(path, staging / rel, policy, context, file_options),
|
|
493
|
+
)
|
|
494
|
+
report = BatchReport("sanitize", shown_root, recursive, results, _skipped(discovery, mode))
|
|
495
|
+
for result in results:
|
|
496
|
+
if result.report is not None:
|
|
497
|
+
result.report.output["path"] = f"{shown_out}/{result.display}"
|
|
498
|
+
if result.report.output.get("written"):
|
|
499
|
+
result.report.output["written"] = False
|
|
500
|
+
result.report.output["staged"] = True
|
|
501
|
+
if report.exit_code != 0:
|
|
502
|
+
report.output = {
|
|
503
|
+
"written": False,
|
|
504
|
+
"path": shown_out,
|
|
505
|
+
"withheld_reason": "file_failed",
|
|
506
|
+
}
|
|
507
|
+
return report
|
|
508
|
+
if os.path.lexists(output):
|
|
509
|
+
raise OutputWriteError(
|
|
510
|
+
"output directory appeared during processing; nothing was published",
|
|
511
|
+
code="output_exists",
|
|
512
|
+
)
|
|
513
|
+
try:
|
|
514
|
+
os.rename(staging, output)
|
|
515
|
+
except OSError:
|
|
516
|
+
raise OutputWriteError(
|
|
517
|
+
"cannot publish the output directory", code="output_publish_failed"
|
|
518
|
+
) from None
|
|
519
|
+
published = True
|
|
520
|
+
for result in results:
|
|
521
|
+
if result.report is not None:
|
|
522
|
+
result.report.output.pop("staged", None)
|
|
523
|
+
result.report.output["written"] = True
|
|
524
|
+
report.output = {"written": True, "path": shown_out}
|
|
525
|
+
return report
|
|
526
|
+
finally:
|
|
527
|
+
if not published:
|
|
528
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
529
|
+
if staging.exists():
|
|
530
|
+
logger.warning(
|
|
531
|
+
"could not remove the staging directory %s; delete it manually",
|
|
532
|
+
staging.name,
|
|
533
|
+
)
|