ciphergate-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,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