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,241 @@
|
|
|
1
|
+
"""CSV adapter built on the standard ``csv`` module (never naive comma splitting).
|
|
2
|
+
|
|
3
|
+
* Header row is required. Header cells are scanned like JSON keys (``header_items``, record index
|
|
4
|
+
``-1``): a label can itself contain data. Duplicate headers are rejected by default; with
|
|
5
|
+
``csv_duplicate_headers: disambiguate`` they are labelled ``name#2`` internally while the
|
|
6
|
+
original header text is written back unchanged.
|
|
7
|
+
* Empty header cells are labelled ``#<column number>`` and are not used for field-name heuristics.
|
|
8
|
+
* Rows with a different width than the header are parse errors (fail, or drop when configured).
|
|
9
|
+
* BOM and CRLF/LF line endings are preserved on output. All values stay text (CSV is untyped).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
from collections.abc import Generator, Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import IO, Any
|
|
19
|
+
|
|
20
|
+
from ciphergate.constants import InputFormat
|
|
21
|
+
from ciphergate.errors import InputFormatError, InputLimitError
|
|
22
|
+
from ciphergate.models.finding import ScanItem
|
|
23
|
+
from ciphergate.readers.base import (
|
|
24
|
+
ReaderOptions,
|
|
25
|
+
ReadStats,
|
|
26
|
+
Record,
|
|
27
|
+
detect_newline,
|
|
28
|
+
has_utf8_bom,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def _field_limit(limit: int) -> Iterator[None]:
|
|
34
|
+
previous = csv.field_size_limit()
|
|
35
|
+
csv.field_size_limit(limit)
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
csv.field_size_limit(previous)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _CountingText:
|
|
43
|
+
"""Wraps a text stream, counting encoded bytes so the total input limit is enforced."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, inner: IO[str], encoding: str, limit: int, stats: ReadStats) -> None:
|
|
46
|
+
self._inner = inner
|
|
47
|
+
self._encoding = encoding
|
|
48
|
+
self._limit = limit
|
|
49
|
+
self._stats = stats
|
|
50
|
+
|
|
51
|
+
def readline(self, size: int = -1) -> str:
|
|
52
|
+
line = self._inner.readline(size)
|
|
53
|
+
self._stats.bytes_read += len(line.encode(self._encoding, errors="replace"))
|
|
54
|
+
if self._stats.bytes_read > self._limit:
|
|
55
|
+
raise InputLimitError("input exceeds max_input_bytes", code="size_limit_exceeded")
|
|
56
|
+
return line
|
|
57
|
+
|
|
58
|
+
def __iter__(self) -> Iterator[str]:
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __next__(self) -> str:
|
|
62
|
+
line = self.readline()
|
|
63
|
+
if not line:
|
|
64
|
+
raise StopIteration
|
|
65
|
+
return line
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CSVReader:
|
|
69
|
+
format = InputFormat.CSV
|
|
70
|
+
|
|
71
|
+
def __init__(self, path: Path, options: ReaderOptions) -> None:
|
|
72
|
+
self.path = path
|
|
73
|
+
self.options = options
|
|
74
|
+
self.stats = ReadStats()
|
|
75
|
+
self.header: list[str] | None = None
|
|
76
|
+
self.labels: list[str] = []
|
|
77
|
+
self.field_names: list[str | None] = []
|
|
78
|
+
self.has_bom = options.encoding.startswith("utf-8") and has_utf8_bom(path)
|
|
79
|
+
self.newline = detect_newline(path)
|
|
80
|
+
self.delimiter = options.runtime.csv_delimiter
|
|
81
|
+
|
|
82
|
+
def _build_labels(self, header: list[str]) -> None:
|
|
83
|
+
labels: list[str] = []
|
|
84
|
+
names: list[str | None] = []
|
|
85
|
+
counts: dict[str, int] = {}
|
|
86
|
+
for i, name in enumerate(header):
|
|
87
|
+
if name == "":
|
|
88
|
+
labels.append(f"#{i + 1}")
|
|
89
|
+
names.append(None)
|
|
90
|
+
continue
|
|
91
|
+
counts[name] = counts.get(name, 0) + 1
|
|
92
|
+
if counts[name] > 1:
|
|
93
|
+
if self.options.policy_settings.csv_duplicate_headers == "reject":
|
|
94
|
+
raise InputFormatError(
|
|
95
|
+
f"duplicate CSV header in column {i + 1}", code="csv_duplicate_header"
|
|
96
|
+
)
|
|
97
|
+
labels.append(f"{name}#{counts[name]}")
|
|
98
|
+
else:
|
|
99
|
+
labels.append(name)
|
|
100
|
+
names.append(name)
|
|
101
|
+
self.labels, self.field_names = labels, names
|
|
102
|
+
|
|
103
|
+
def header_items(self) -> Iterator[ScanItem]:
|
|
104
|
+
"""Header cells as key items. Reads only up to the first data row to learn the header."""
|
|
105
|
+
rows = self.records()
|
|
106
|
+
try:
|
|
107
|
+
next(rows, None)
|
|
108
|
+
finally:
|
|
109
|
+
rows.close()
|
|
110
|
+
for col, name in enumerate(self.header or []):
|
|
111
|
+
if name:
|
|
112
|
+
yield ScanItem(
|
|
113
|
+
record_index=-1,
|
|
114
|
+
line=1,
|
|
115
|
+
field_path=self.labels[col],
|
|
116
|
+
field_name=None,
|
|
117
|
+
value=name,
|
|
118
|
+
column_index=col,
|
|
119
|
+
in_key=True,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def records(self) -> Generator[Record, None, None]:
|
|
123
|
+
self.header = None
|
|
124
|
+
self.stats = ReadStats()
|
|
125
|
+
encoding = (
|
|
126
|
+
"utf-8-sig" if self.options.encoding.startswith("utf-8") else self.options.encoding
|
|
127
|
+
)
|
|
128
|
+
runtime = self.options.runtime
|
|
129
|
+
try:
|
|
130
|
+
with (
|
|
131
|
+
_field_limit(runtime.max_record_bytes),
|
|
132
|
+
self.path.open("r", encoding=encoding, newline="", errors="strict") as fh,
|
|
133
|
+
):
|
|
134
|
+
counted = _CountingText(fh, encoding, runtime.max_input_bytes, self.stats)
|
|
135
|
+
reader = csv.reader(counted, delimiter=self.delimiter, strict=True)
|
|
136
|
+
index = 0
|
|
137
|
+
for row in reader:
|
|
138
|
+
line = reader.line_num
|
|
139
|
+
if self.header is None:
|
|
140
|
+
self.header = row
|
|
141
|
+
self._build_labels(row)
|
|
142
|
+
continue
|
|
143
|
+
if not row:
|
|
144
|
+
self.stats.blank_lines += 1
|
|
145
|
+
if self.options.policy_settings.preserve_blank_lines:
|
|
146
|
+
yield Record(index=-1, line=line, data=[])
|
|
147
|
+
continue
|
|
148
|
+
if len(row) != len(self.header):
|
|
149
|
+
self.stats.parse_errors.append((line, "csv_row_width"))
|
|
150
|
+
if self.options.policy_settings.fail_on_parse_error:
|
|
151
|
+
raise InputFormatError(
|
|
152
|
+
f"CSV row ending at line {line} has {len(row)} fields, header has "
|
|
153
|
+
f"{len(self.header)}",
|
|
154
|
+
code="csv_row_width",
|
|
155
|
+
)
|
|
156
|
+
yield Record(
|
|
157
|
+
index=index,
|
|
158
|
+
line=line,
|
|
159
|
+
data=row,
|
|
160
|
+
malformed=True,
|
|
161
|
+
error_code="csv_row_width",
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
yield Record(index=index, line=line, data=row)
|
|
165
|
+
index += 1
|
|
166
|
+
self.stats.records = index
|
|
167
|
+
except UnicodeDecodeError:
|
|
168
|
+
raise InputFormatError(
|
|
169
|
+
f"input is not valid {self.options.encoding}", code="decode_error"
|
|
170
|
+
) from None
|
|
171
|
+
except csv.Error as exc:
|
|
172
|
+
line = None
|
|
173
|
+
if "field larger than field limit" in str(exc):
|
|
174
|
+
raise InputLimitError(
|
|
175
|
+
"a CSV field exceeds max_record_bytes", code="record_too_large"
|
|
176
|
+
) from None
|
|
177
|
+
raise InputFormatError(
|
|
178
|
+
f"malformed CSV near line {line or 'unknown'}", code="csv_syntax"
|
|
179
|
+
) from None
|
|
180
|
+
|
|
181
|
+
def iter_items(self, record: Record) -> Iterator[ScanItem]:
|
|
182
|
+
if record.malformed or record.index < 0:
|
|
183
|
+
return
|
|
184
|
+
for col, value in enumerate(record.data):
|
|
185
|
+
yield ScanItem(
|
|
186
|
+
record_index=record.index,
|
|
187
|
+
line=record.line,
|
|
188
|
+
field_path=self.labels[col],
|
|
189
|
+
field_name=self.field_names[col],
|
|
190
|
+
value=value,
|
|
191
|
+
column_index=col,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CSVWriter:
|
|
196
|
+
"""Writes the header lazily: the reader only knows it once pass two has started reading."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, stream: IO[str], reader: CSVReader, dropped_columns: set[int]) -> None:
|
|
199
|
+
self._stream = stream
|
|
200
|
+
self._reader = reader
|
|
201
|
+
self._dropped = dropped_columns
|
|
202
|
+
self._keep: list[int] | None = None
|
|
203
|
+
self._writer = csv.writer(
|
|
204
|
+
stream,
|
|
205
|
+
delimiter=reader.delimiter,
|
|
206
|
+
lineterminator=reader.newline,
|
|
207
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
208
|
+
)
|
|
209
|
+
# QUOTE_MINIMAL only quotes characters of ``lineterminator``; a lone CR/LF inside a field
|
|
210
|
+
# would otherwise be written unquoted and corrupt the value.
|
|
211
|
+
self._quote_all = csv.writer(
|
|
212
|
+
stream,
|
|
213
|
+
delimiter=reader.delimiter,
|
|
214
|
+
lineterminator=reader.newline,
|
|
215
|
+
quoting=csv.QUOTE_ALL,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _writerow(self, row: list[Any]) -> None:
|
|
219
|
+
needs_quotes = any(isinstance(v, str) and ("\r" in v or "\n" in v) for v in row)
|
|
220
|
+
(self._quote_all if needs_quotes else self._writer).writerow(row)
|
|
221
|
+
|
|
222
|
+
def _ensure_header(self) -> list[int]:
|
|
223
|
+
if self._keep is None:
|
|
224
|
+
header = self._reader.header
|
|
225
|
+
self._keep = [i for i in range(len(header or [])) if i not in self._dropped]
|
|
226
|
+
if self._reader.has_bom:
|
|
227
|
+
self._stream.write("")
|
|
228
|
+
if header is not None:
|
|
229
|
+
self._writerow([header[i] for i in self._keep])
|
|
230
|
+
return self._keep
|
|
231
|
+
|
|
232
|
+
def write_record(self, record: Record, data: Any) -> None:
|
|
233
|
+
keep = self._ensure_header()
|
|
234
|
+
if record.index < 0:
|
|
235
|
+
self._writerow([])
|
|
236
|
+
return
|
|
237
|
+
self._writerow([data[i] for i in keep])
|
|
238
|
+
|
|
239
|
+
def close(self) -> None:
|
|
240
|
+
self._ensure_header()
|
|
241
|
+
self._stream.flush()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""JSON adapter (whole-document parse, bounded by ``max_json_document_bytes``).
|
|
2
|
+
|
|
3
|
+
* A top-level array is treated as a sequence of records (``/0``, ``/1``, ...); any other top-level
|
|
4
|
+
value is a single record.
|
|
5
|
+
* Paths are RFC 6901 JSON Pointers (``~`` -> ``~0``, ``/`` -> ``~1``).
|
|
6
|
+
* Nesting depth is checked by a string-aware pre-scan before parsing.
|
|
7
|
+
* Duplicate object keys and NaN/Infinity are rejected (strict JSON).
|
|
8
|
+
* Parser error messages are replaced with position-only messages (never source excerpts).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, Any
|
|
17
|
+
|
|
18
|
+
from ciphergate.constants import InputFormat
|
|
19
|
+
from ciphergate.errors import InputFormatError, InputLimitError
|
|
20
|
+
from ciphergate.models.finding import ScanItem
|
|
21
|
+
from ciphergate.readers.base import ReaderOptions, ReadStats, Record, detect_newline
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _DuplicateKey(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BadConstant(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pointer_escape(segment: str) -> str:
|
|
33
|
+
return segment.replace("~", "~0").replace("/", "~1")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_depth(text: str, max_depth: int) -> None:
|
|
37
|
+
depth = 0
|
|
38
|
+
in_string = False
|
|
39
|
+
escape = False
|
|
40
|
+
for ch in text:
|
|
41
|
+
if in_string:
|
|
42
|
+
if escape:
|
|
43
|
+
escape = False
|
|
44
|
+
elif ch == "\\":
|
|
45
|
+
escape = True
|
|
46
|
+
elif ch == '"':
|
|
47
|
+
in_string = False
|
|
48
|
+
continue
|
|
49
|
+
if ch == '"':
|
|
50
|
+
in_string = True
|
|
51
|
+
elif ch in "[{":
|
|
52
|
+
depth += 1
|
|
53
|
+
if depth > max_depth:
|
|
54
|
+
raise InputLimitError(
|
|
55
|
+
f"JSON nesting exceeds max depth {max_depth}", code="json_too_deep"
|
|
56
|
+
)
|
|
57
|
+
elif ch in "]}":
|
|
58
|
+
depth -= 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
62
|
+
out: dict[str, Any] = {}
|
|
63
|
+
for key, value in pairs:
|
|
64
|
+
if key in out:
|
|
65
|
+
raise _DuplicateKey
|
|
66
|
+
out[key] = value
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reject_constant(_: str) -> Any:
|
|
71
|
+
raise _BadConstant
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_json_text(text: str, max_depth: int, *, where: str = "document") -> Any:
|
|
75
|
+
check_depth(text, max_depth)
|
|
76
|
+
try:
|
|
77
|
+
return json.loads(text, object_pairs_hook=_pairs_hook, parse_constant=_reject_constant)
|
|
78
|
+
except _DuplicateKey:
|
|
79
|
+
raise InputFormatError(
|
|
80
|
+
f"duplicate object key in JSON {where}", code="json_duplicate_key"
|
|
81
|
+
) from None
|
|
82
|
+
except _BadConstant:
|
|
83
|
+
raise InputFormatError(
|
|
84
|
+
f"NaN/Infinity are not valid JSON ({where})", code="json_invalid_constant"
|
|
85
|
+
) from None
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise InputFormatError(
|
|
88
|
+
f"invalid JSON {where} at line {exc.lineno}, column {exc.colno}", code="json_syntax"
|
|
89
|
+
) from None
|
|
90
|
+
except RecursionError:
|
|
91
|
+
raise InputLimitError("JSON nesting too deep", code="json_too_deep") from None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def iter_json_items(
|
|
95
|
+
node: Any,
|
|
96
|
+
pointer: str,
|
|
97
|
+
field_name: str | None,
|
|
98
|
+
record_index: int,
|
|
99
|
+
line: int | None,
|
|
100
|
+
scan_keys: bool,
|
|
101
|
+
) -> Iterator[ScanItem]:
|
|
102
|
+
if isinstance(node, dict):
|
|
103
|
+
for key, value in node.items():
|
|
104
|
+
child = f"{pointer}/{pointer_escape(key)}"
|
|
105
|
+
if scan_keys:
|
|
106
|
+
yield ScanItem(record_index, line, child, None, key, in_key=True)
|
|
107
|
+
yield from iter_json_items(value, child, key, record_index, line, scan_keys)
|
|
108
|
+
elif isinstance(node, list):
|
|
109
|
+
for i, value in enumerate(node):
|
|
110
|
+
yield from iter_json_items(
|
|
111
|
+
value, f"{pointer}/{i}", field_name, record_index, line, scan_keys
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
yield ScanItem(record_index, line, pointer, field_name, node)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class JSONReader:
|
|
118
|
+
format = InputFormat.JSON
|
|
119
|
+
|
|
120
|
+
def __init__(self, path: Path, options: ReaderOptions) -> None:
|
|
121
|
+
self.path = path
|
|
122
|
+
self.options = options
|
|
123
|
+
self.stats = ReadStats()
|
|
124
|
+
self.top_level_array = False
|
|
125
|
+
self.newline = detect_newline(path)
|
|
126
|
+
|
|
127
|
+
def records(self) -> Iterator[Record]:
|
|
128
|
+
runtime = self.options.runtime
|
|
129
|
+
limit = min(runtime.max_json_document_bytes, runtime.max_input_bytes)
|
|
130
|
+
with self.path.open("rb") as fh:
|
|
131
|
+
data = fh.read(limit + 1)
|
|
132
|
+
if len(data) > limit:
|
|
133
|
+
raise InputLimitError(
|
|
134
|
+
f"JSON document exceeds {limit} bytes", code="size_limit_exceeded"
|
|
135
|
+
)
|
|
136
|
+
self.stats.bytes_read = len(data)
|
|
137
|
+
encoding = self.options.encoding
|
|
138
|
+
try:
|
|
139
|
+
text = data.decode("utf-8-sig" if encoding.startswith("utf-8") else encoding)
|
|
140
|
+
except UnicodeDecodeError:
|
|
141
|
+
raise InputFormatError(f"input is not valid {encoding}", code="decode_error") from None
|
|
142
|
+
doc = parse_json_text(text, runtime.max_json_depth)
|
|
143
|
+
if isinstance(doc, list):
|
|
144
|
+
self.top_level_array = True
|
|
145
|
+
for i, element in enumerate(doc):
|
|
146
|
+
self.stats.records = i + 1
|
|
147
|
+
yield Record(index=i, line=None, data=element)
|
|
148
|
+
else:
|
|
149
|
+
self.stats.records = 1
|
|
150
|
+
yield Record(index=0, line=None, data=doc)
|
|
151
|
+
|
|
152
|
+
def iter_items(self, record: Record) -> Iterator[ScanItem]:
|
|
153
|
+
prefix = f"/{record.index}" if self.top_level_array else ""
|
|
154
|
+
yield from iter_json_items(
|
|
155
|
+
record.data,
|
|
156
|
+
prefix,
|
|
157
|
+
None,
|
|
158
|
+
record.index,
|
|
159
|
+
None,
|
|
160
|
+
self.options.policy_settings.scan_json_keys,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class JSONWriter:
|
|
165
|
+
"""Buffers records and writes one UTF-8 JSON document (indent 2) on close."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, stream: IO[str], reader: JSONReader) -> None:
|
|
168
|
+
self._stream = stream
|
|
169
|
+
self._reader = reader
|
|
170
|
+
self._newline = reader.newline
|
|
171
|
+
self._items: list[Any] = []
|
|
172
|
+
|
|
173
|
+
def write_record(self, record: Record, data: Any) -> None:
|
|
174
|
+
self._items.append(data)
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
doc: Any = (
|
|
178
|
+
self._items
|
|
179
|
+
if self._reader.top_level_array
|
|
180
|
+
else (self._items[0] if self._items else None)
|
|
181
|
+
)
|
|
182
|
+
text = json.dumps(doc, ensure_ascii=False, indent=2, allow_nan=False)
|
|
183
|
+
self._stream.write(text.replace("\n", self._newline) + self._newline)
|
|
184
|
+
self._stream.flush()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""JSONL / NDJSON adapter: one JSON value per non-empty line, streamed with bounded memory.
|
|
2
|
+
|
|
3
|
+
Blank lines are dropped from output unless ``preserve_blank_lines`` is set. Malformed lines fail the
|
|
4
|
+
run by default; with ``fail_on_parse_error: false`` + ``malformed_record_action: drop_record`` they
|
|
5
|
+
are dropped and counted. A malformed line is never copied unchanged into the output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Any
|
|
14
|
+
|
|
15
|
+
from ciphergate.constants import InputFormat
|
|
16
|
+
from ciphergate.errors import InputFormatError
|
|
17
|
+
from ciphergate.models.finding import ScanItem
|
|
18
|
+
from ciphergate.readers.base import (
|
|
19
|
+
ReaderOptions,
|
|
20
|
+
ReadStats,
|
|
21
|
+
Record,
|
|
22
|
+
decode_line,
|
|
23
|
+
read_bounded_lines,
|
|
24
|
+
split_line_ending,
|
|
25
|
+
)
|
|
26
|
+
from ciphergate.readers.json_reader import iter_json_items, parse_json_text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JSONLReader:
|
|
30
|
+
format = InputFormat.JSONL
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: Path, options: ReaderOptions) -> None:
|
|
33
|
+
self.path = path
|
|
34
|
+
self.options = options
|
|
35
|
+
self.stats = ReadStats()
|
|
36
|
+
|
|
37
|
+
def records(self) -> Iterator[Record]:
|
|
38
|
+
settings = self.options.policy_settings
|
|
39
|
+
index = 0
|
|
40
|
+
with self.path.open("rb") as fh:
|
|
41
|
+
for line_no, raw in read_bounded_lines(fh, self.options, self.stats):
|
|
42
|
+
text = decode_line(raw, self.options.encoding, line_no, first=line_no == 1)
|
|
43
|
+
content, ending = split_line_ending(text)
|
|
44
|
+
if not content.strip():
|
|
45
|
+
self.stats.blank_lines += 1
|
|
46
|
+
if settings.preserve_blank_lines:
|
|
47
|
+
yield Record(index=-1, line=line_no, data=content, line_ending=ending)
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
data = parse_json_text(
|
|
51
|
+
content, self.options.runtime.max_json_depth, where=f"line {line_no}"
|
|
52
|
+
)
|
|
53
|
+
except InputFormatError as exc:
|
|
54
|
+
self.stats.parse_errors.append((line_no, exc.code))
|
|
55
|
+
if settings.fail_on_parse_error:
|
|
56
|
+
raise InputFormatError(
|
|
57
|
+
f"invalid JSON on line {line_no}", code=exc.code
|
|
58
|
+
) from None
|
|
59
|
+
yield Record(
|
|
60
|
+
index=index,
|
|
61
|
+
line=line_no,
|
|
62
|
+
data=None,
|
|
63
|
+
line_ending=ending,
|
|
64
|
+
malformed=True,
|
|
65
|
+
error_code=exc.code,
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
yield Record(index=index, line=line_no, data=data, line_ending=ending)
|
|
69
|
+
index += 1
|
|
70
|
+
self.stats.records = index
|
|
71
|
+
|
|
72
|
+
def iter_items(self, record: Record) -> Iterator[ScanItem]:
|
|
73
|
+
if record.malformed or record.index < 0:
|
|
74
|
+
return
|
|
75
|
+
yield from iter_json_items(
|
|
76
|
+
record.data,
|
|
77
|
+
"",
|
|
78
|
+
None,
|
|
79
|
+
record.index,
|
|
80
|
+
record.line,
|
|
81
|
+
self.options.policy_settings.scan_json_keys,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class JSONLWriter:
|
|
86
|
+
def __init__(self, stream: IO[str]) -> None:
|
|
87
|
+
self._stream = stream
|
|
88
|
+
|
|
89
|
+
def write_record(self, record: Record, data: Any) -> None:
|
|
90
|
+
ending = record.line_ending or "\n"
|
|
91
|
+
if record.index < 0:
|
|
92
|
+
self._stream.write(ending)
|
|
93
|
+
return
|
|
94
|
+
self._stream.write(json.dumps(data, ensure_ascii=False, allow_nan=False) + ending)
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
self._stream.flush()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""JSON-in-log adapter: text/log lines that embed JSON objects (``ERROR {"user": ...}``).
|
|
2
|
+
|
|
3
|
+
Detection never relies on the parse alone. Every line is scanned exactly like ``text``; the
|
|
4
|
+
parse only adds field context on top, for each JSON object found in the line:
|
|
5
|
+
|
|
6
|
+
* findings inside a value are attributed to it (``field_path`` = JSON Pointer inside the object,
|
|
7
|
+
``field_name`` = its key), so policy ``field_names``/``field_paths`` rules apply;
|
|
8
|
+
* field-name heuristics and policy classifications run on every key and cover the whole value;
|
|
9
|
+
* string values that contain JSON escapes (``\\u0040``) are also decoded and scanned, because the
|
|
10
|
+
raw-text scan cannot see through escapes; a match there covers the whole value.
|
|
11
|
+
|
|
12
|
+
Output keeps the line as it was; only protected spans change. Replacements inside a JSON string
|
|
13
|
+
are JSON-escaped, and a replaced number/boolean/null becomes a JSON string, so every embedded
|
|
14
|
+
object stays valid JSON; this is re-checked after rewriting and a violation fails the run.
|
|
15
|
+
|
|
16
|
+
Only objects (``{...}``) are recognised, at most ``MAX_CANDIDATES`` start positions per line,
|
|
17
|
+
nested at most ``max_depth`` levels. Text that is not valid JSON gets no field context (it is
|
|
18
|
+
still scanned as text). ``drop_field`` is not supported (it becomes ``review``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ciphergate.constants import InputFormat
|
|
28
|
+
from ciphergate.readers.json_reader import pointer_escape
|
|
29
|
+
from ciphergate.readers.line_structure import FieldRegion, FieldToken, LineStructure
|
|
30
|
+
from ciphergate.readers.text_reader import TextReader
|
|
31
|
+
|
|
32
|
+
MAX_CANDIDATES = 16
|
|
33
|
+
_WS = " \t\r\n"
|
|
34
|
+
_NUMBER_RE = re.compile(r"-?(?:0|[1-9][0-9]{0,400})(?:\.[0-9]{1,400})?(?:[eE][+-]?[0-9]{1,5})?")
|
|
35
|
+
_LITERALS = (("true", True), ("false", False), ("null", None))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Invalid(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Parser:
|
|
43
|
+
def __init__(self, line: str, max_depth: int) -> None:
|
|
44
|
+
self.s = line
|
|
45
|
+
self.n = len(line)
|
|
46
|
+
self.max_depth = max_depth
|
|
47
|
+
self.tokens: list[FieldToken] = []
|
|
48
|
+
|
|
49
|
+
def ws(self, i: int) -> int:
|
|
50
|
+
while i < self.n and self.s[i] in _WS:
|
|
51
|
+
i += 1
|
|
52
|
+
return i
|
|
53
|
+
|
|
54
|
+
def string(self, i: int) -> tuple[str, int, bool]:
|
|
55
|
+
"""Parse a JSON string at ``i`` (the opening quote); returns (value, end, escaped)."""
|
|
56
|
+
j = i + 1
|
|
57
|
+
escaped = False
|
|
58
|
+
while j < self.n:
|
|
59
|
+
ch = self.s[j]
|
|
60
|
+
if ch == '"':
|
|
61
|
+
raw = self.s[i : j + 1]
|
|
62
|
+
try:
|
|
63
|
+
value = json.loads(raw)
|
|
64
|
+
except ValueError:
|
|
65
|
+
raise _Invalid from None
|
|
66
|
+
return value, j + 1, escaped
|
|
67
|
+
if ch == "\\":
|
|
68
|
+
escaped = True
|
|
69
|
+
j += 2
|
|
70
|
+
continue
|
|
71
|
+
if ord(ch) < 0x20:
|
|
72
|
+
raise _Invalid
|
|
73
|
+
j += 1
|
|
74
|
+
raise _Invalid
|
|
75
|
+
|
|
76
|
+
def value(self, i: int, pointer: str, key: str | None, depth: int) -> int:
|
|
77
|
+
if i >= self.n:
|
|
78
|
+
raise _Invalid
|
|
79
|
+
ch = self.s[i]
|
|
80
|
+
if ch == "{":
|
|
81
|
+
return self.obj(i, pointer, depth + 1)
|
|
82
|
+
if ch == "[":
|
|
83
|
+
return self.array(i, pointer, key, depth + 1)
|
|
84
|
+
if ch == '"':
|
|
85
|
+
value, end, escaped = self.string(i)
|
|
86
|
+
self.tokens.append(FieldToken("string", i + 1, end - 1, pointer, key, value, escaped))
|
|
87
|
+
return end
|
|
88
|
+
for text, literal in _LITERALS:
|
|
89
|
+
if self.s.startswith(text, i):
|
|
90
|
+
self.tokens.append(FieldToken("literal", i, i + len(text), pointer, key, literal))
|
|
91
|
+
return i + len(text)
|
|
92
|
+
m = _NUMBER_RE.match(self.s, i)
|
|
93
|
+
if not m:
|
|
94
|
+
raise _Invalid
|
|
95
|
+
number = json.loads(m.group())
|
|
96
|
+
self.tokens.append(FieldToken("literal", i, m.end(), pointer, key, number))
|
|
97
|
+
return m.end()
|
|
98
|
+
|
|
99
|
+
def obj(self, i: int, pointer: str, depth: int) -> int:
|
|
100
|
+
if depth > self.max_depth:
|
|
101
|
+
raise _Invalid
|
|
102
|
+
i = self.ws(i + 1)
|
|
103
|
+
if i < self.n and self.s[i] == "}":
|
|
104
|
+
return i + 1
|
|
105
|
+
while True:
|
|
106
|
+
if i >= self.n or self.s[i] != '"':
|
|
107
|
+
raise _Invalid
|
|
108
|
+
key, key_end, escaped = self.string(i)
|
|
109
|
+
child = f"{pointer}/{pointer_escape(key)}"
|
|
110
|
+
self.tokens.append(FieldToken("key", i + 1, key_end - 1, child, key, key, escaped))
|
|
111
|
+
i = self.ws(key_end)
|
|
112
|
+
if i >= self.n or self.s[i] != ":":
|
|
113
|
+
raise _Invalid
|
|
114
|
+
i = self.value(self.ws(i + 1), child, key, depth)
|
|
115
|
+
i = self.ws(i)
|
|
116
|
+
if i < self.n and self.s[i] == ",":
|
|
117
|
+
i = self.ws(i + 1)
|
|
118
|
+
continue
|
|
119
|
+
if i < self.n and self.s[i] == "}":
|
|
120
|
+
return i + 1
|
|
121
|
+
raise _Invalid
|
|
122
|
+
|
|
123
|
+
def array(self, i: int, pointer: str, key: str | None, depth: int) -> int:
|
|
124
|
+
if depth > self.max_depth:
|
|
125
|
+
raise _Invalid
|
|
126
|
+
i = self.ws(i + 1)
|
|
127
|
+
if i < self.n and self.s[i] == "]":
|
|
128
|
+
return i + 1
|
|
129
|
+
index = 0
|
|
130
|
+
while True:
|
|
131
|
+
i = self.ws(self.value(i, f"{pointer}/{index}", key, depth))
|
|
132
|
+
index += 1
|
|
133
|
+
if i < self.n and self.s[i] == ",":
|
|
134
|
+
i = self.ws(i + 1)
|
|
135
|
+
continue
|
|
136
|
+
if i < self.n and self.s[i] == "]":
|
|
137
|
+
return i + 1
|
|
138
|
+
raise _Invalid
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_regions(line: str, max_depth: int = 64) -> list[FieldRegion]:
|
|
142
|
+
"""JSON objects embedded in ``line`` (non-overlapping, left to right)."""
|
|
143
|
+
regions: list[FieldRegion] = []
|
|
144
|
+
i = 0
|
|
145
|
+
attempts = 0
|
|
146
|
+
while attempts < MAX_CANDIDATES:
|
|
147
|
+
start = line.find("{", i)
|
|
148
|
+
if start < 0:
|
|
149
|
+
break
|
|
150
|
+
attempts += 1
|
|
151
|
+
parser = _Parser(line, max_depth)
|
|
152
|
+
try:
|
|
153
|
+
end = parser.obj(start, "", 1)
|
|
154
|
+
except (_Invalid, RecursionError):
|
|
155
|
+
i = start + 1
|
|
156
|
+
continue
|
|
157
|
+
regions.append(FieldRegion(start, end, tuple(parser.tokens)))
|
|
158
|
+
i = end
|
|
159
|
+
return regions
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _unescape(owner: FieldToken, raw: str) -> Any:
|
|
163
|
+
try:
|
|
164
|
+
return json.loads(f'"{raw}"')
|
|
165
|
+
except ValueError:
|
|
166
|
+
return raw
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _escape(owner: FieldToken, text: str) -> str:
|
|
170
|
+
quoted = json.dumps(text, ensure_ascii=False)
|
|
171
|
+
return quoted if owner.kind == "literal" else quoted[1:-1]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
JSONLOG_STRUCTURE = LineStructure(parse=parse_regions, unescape=_unescape, escape=_escape)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class JsonLogReader(TextReader):
|
|
178
|
+
format = InputFormat.JSONLOG
|