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,113 @@
|
|
|
1
|
+
"""Format adapters (readers + writers): CSV, JSON, JSONL/NDJSON, text/log and log formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import IO
|
|
7
|
+
|
|
8
|
+
from ciphergate.constants import InputFormat
|
|
9
|
+
from ciphergate.errors import InputFormatError, UsageError
|
|
10
|
+
from ciphergate.readers.accesslog_reader import ACCESSLOG_STRUCTURE, AccessLogReader
|
|
11
|
+
from ciphergate.readers.base import Reader, ReaderOptions, Record, Writer
|
|
12
|
+
from ciphergate.readers.csv_reader import CSVReader, CSVWriter
|
|
13
|
+
from ciphergate.readers.json_reader import JSONReader, JSONWriter
|
|
14
|
+
from ciphergate.readers.jsonl_reader import JSONLReader, JSONLWriter
|
|
15
|
+
from ciphergate.readers.jsonlog_reader import JSONLOG_STRUCTURE, JsonLogReader
|
|
16
|
+
from ciphergate.readers.line_structure import LineStructure
|
|
17
|
+
from ciphergate.readers.logfmt_reader import LogfmtReader
|
|
18
|
+
from ciphergate.readers.stacktrace_reader import StackTraceReader
|
|
19
|
+
from ciphergate.readers.syslog_reader import SYSLOG_STRUCTURE, SyslogReader
|
|
20
|
+
from ciphergate.readers.text_reader import TextReader, TextWriter
|
|
21
|
+
|
|
22
|
+
# Line formats whose lines are parsed into fields (see readers/line_structure.py).
|
|
23
|
+
STRUCTURED_LINE_FORMATS: dict[InputFormat, LineStructure] = {
|
|
24
|
+
InputFormat.JSONLOG: JSONLOG_STRUCTURE,
|
|
25
|
+
InputFormat.ACCESSLOG: ACCESSLOG_STRUCTURE,
|
|
26
|
+
InputFormat.SYSLOG: SYSLOG_STRUCTURE,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
EXTENSION_FORMATS: dict[str, InputFormat] = {
|
|
30
|
+
".csv": InputFormat.CSV,
|
|
31
|
+
".json": InputFormat.JSON,
|
|
32
|
+
".jsonl": InputFormat.JSONL,
|
|
33
|
+
".ndjson": InputFormat.JSONL,
|
|
34
|
+
".txt": InputFormat.TEXT,
|
|
35
|
+
".log": InputFormat.TEXT,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_JSON_PREFIXES = (
|
|
39
|
+
b"{",
|
|
40
|
+
b"[",
|
|
41
|
+
b'"',
|
|
42
|
+
b"-",
|
|
43
|
+
b"true",
|
|
44
|
+
b"false",
|
|
45
|
+
b"null",
|
|
46
|
+
*(bytes([d]) for d in b"0123456789"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detect_format(path: Path, explicit: InputFormat | None = None) -> InputFormat:
|
|
51
|
+
"""Explicit ``--format`` wins; else use the extension, refusing contradicting content."""
|
|
52
|
+
if explicit is not None:
|
|
53
|
+
return explicit
|
|
54
|
+
fmt = EXTENSION_FORMATS.get(path.suffix.lower())
|
|
55
|
+
if fmt is None:
|
|
56
|
+
raise UsageError(
|
|
57
|
+
"cannot infer the input format from this file extension; pass --format "
|
|
58
|
+
+ "|".join(InputFormat),
|
|
59
|
+
code="format_required",
|
|
60
|
+
)
|
|
61
|
+
if fmt in (InputFormat.JSON, InputFormat.JSONL):
|
|
62
|
+
with path.open("rb") as fh:
|
|
63
|
+
head = fh.read(4096).lstrip(b"\xef\xbb\xbf \t\r\n")
|
|
64
|
+
if head and not head.startswith(_JSON_PREFIXES):
|
|
65
|
+
raise InputFormatError(
|
|
66
|
+
"file content does not look like JSON despite its extension; pass --format",
|
|
67
|
+
code="format_mismatch",
|
|
68
|
+
)
|
|
69
|
+
return fmt
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def open_reader(path: Path, fmt: InputFormat, options: ReaderOptions) -> Reader:
|
|
73
|
+
if fmt == InputFormat.CSV:
|
|
74
|
+
return CSVReader(path, options)
|
|
75
|
+
if fmt == InputFormat.JSON:
|
|
76
|
+
return JSONReader(path, options)
|
|
77
|
+
if fmt == InputFormat.JSONL:
|
|
78
|
+
return JSONLReader(path, options)
|
|
79
|
+
if fmt == InputFormat.LOGFMT:
|
|
80
|
+
return LogfmtReader(path, options)
|
|
81
|
+
if fmt == InputFormat.JSONLOG:
|
|
82
|
+
return JsonLogReader(path, options)
|
|
83
|
+
if fmt == InputFormat.ACCESSLOG:
|
|
84
|
+
return AccessLogReader(path, options)
|
|
85
|
+
if fmt == InputFormat.SYSLOG:
|
|
86
|
+
return SyslogReader(path, options)
|
|
87
|
+
if fmt == InputFormat.STACKTRACE:
|
|
88
|
+
return StackTraceReader(path, options)
|
|
89
|
+
return TextReader(path, options)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def make_writer(stream: IO[str], reader: Reader, dropped_columns: set[int]) -> Writer:
|
|
93
|
+
if isinstance(reader, CSVReader):
|
|
94
|
+
return CSVWriter(stream, reader, dropped_columns)
|
|
95
|
+
if isinstance(reader, JSONReader):
|
|
96
|
+
return JSONWriter(stream, reader)
|
|
97
|
+
if isinstance(reader, JSONLReader):
|
|
98
|
+
return JSONLWriter(stream)
|
|
99
|
+
if isinstance(reader, TextReader):
|
|
100
|
+
return TextWriter(stream, reader)
|
|
101
|
+
raise TypeError("unsupported reader")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"EXTENSION_FORMATS",
|
|
106
|
+
"Reader",
|
|
107
|
+
"ReaderOptions",
|
|
108
|
+
"Record",
|
|
109
|
+
"Writer",
|
|
110
|
+
"detect_format",
|
|
111
|
+
"make_writer",
|
|
112
|
+
"open_reader",
|
|
113
|
+
]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Access-log adapter: NCSA common/combined lines as written by nginx and Apache httpd.
|
|
2
|
+
|
|
3
|
+
``192.0.2.10 - alice [05/Jan/2026:10:00:00 +0000] "GET /p?email=a%40b HTTP/1.1" 200 12 "ref" "ua"``
|
|
4
|
+
|
|
5
|
+
Detection never relies on the parse alone. Every line is scanned exactly like ``text``; the
|
|
6
|
+
parse only adds field context on top:
|
|
7
|
+
|
|
8
|
+
* fields get nginx variable names as field paths: ``/remote_addr``, ``/ident``,
|
|
9
|
+
``/remote_user``, ``/time_local``, ``/request`` (split into ``/request/method``,
|
|
10
|
+
``/request/path``, ``/request/protocol`` when it has the usual three parts), ``/status``,
|
|
11
|
+
``/body_bytes_sent``, ``/http_referer`` and ``/http_user_agent``;
|
|
12
|
+
* query strings of the request target and the referer become fields too
|
|
13
|
+
(``/request/query/<name>``), so field-name heuristics and ``field_names`` rules apply to
|
|
14
|
+
parameters such as ``email`` or ``access_token``;
|
|
15
|
+
* log escapes (``\\x22``, ``\\"``) and percent-encoding are decoded and the decoded value is
|
|
16
|
+
scanned as well, because the raw-text scan cannot see through them.
|
|
17
|
+
|
|
18
|
+
A ``-`` field means "no value" and gets no field context. Output keeps the line as it was; only
|
|
19
|
+
protected spans change. Replacements are re-encoded for their field (percent-encoding in paths
|
|
20
|
+
and query strings, ``\\xHH`` log escapes elsewhere), and the line must still parse with as many
|
|
21
|
+
fields afterwards, or the run fails. Only lines that *start* with the common/combined layout are
|
|
22
|
+
recognised; anything after the user agent (e.g. ``$http_x_forwarded_for``) and lines in other
|
|
23
|
+
layouts get text-mode scanning only. ``drop_field`` is not supported (it becomes ``review``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ciphergate.constants import InputFormat
|
|
32
|
+
from ciphergate.readers.json_reader import pointer_escape
|
|
33
|
+
from ciphergate.readers.line_structure import FieldRegion, FieldToken, LineStructure
|
|
34
|
+
from ciphergate.readers.text_reader import TextReader
|
|
35
|
+
|
|
36
|
+
# Three space-free fields, then the bracketed time. Classes never overlap the separators, so
|
|
37
|
+
# matching is linear.
|
|
38
|
+
_HEAD_RE = re.compile(r'([^\s"]{1,1024}) ([^\s"]{1,1024}) ([^\s"]{1,1024}) \[([^\]\n]{1,128})\] ')
|
|
39
|
+
_SHORT_RE = re.compile(r' ([^\s"]{1,64}) ([^\s"]{1,64})')
|
|
40
|
+
_REQUEST_RE = re.compile(r"([^ ]{1,64}) ([^ ]{1,8192}) ([^ ]{1,64})")
|
|
41
|
+
_MAX_QUOTED = 16384
|
|
42
|
+
_LOG_ESCAPES = {'"': '"', "\\": "\\", "n": "\n", "t": "\t", "r": "\r"}
|
|
43
|
+
# Characters kept literally when percent-encoding a replacement (markers stay readable).
|
|
44
|
+
_URL_SAFE = frozenset(
|
|
45
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*,:@/[]"
|
|
46
|
+
)
|
|
47
|
+
_HEX = "0123456789abcdefABCDEF"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _quoted(line: str, i: int) -> int | None:
|
|
51
|
+
"""End offset of the quoted field whose opening quote is at ``i`` (after the closing quote)."""
|
|
52
|
+
if i >= len(line) or line[i] != '"':
|
|
53
|
+
return None
|
|
54
|
+
j, limit = i + 1, min(len(line), i + 1 + _MAX_QUOTED)
|
|
55
|
+
while j < limit:
|
|
56
|
+
ch = line[j]
|
|
57
|
+
if ch == '"':
|
|
58
|
+
return j + 1
|
|
59
|
+
j += 2 if ch == "\\" else 1
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _log_unescape(raw: str) -> str:
|
|
64
|
+
"""Undo nginx (``\\xHH``) and Apache (``\\"``, ``\\\\``, ``\\n``) log escapes."""
|
|
65
|
+
if "\\" not in raw:
|
|
66
|
+
return raw
|
|
67
|
+
out: list[str] = []
|
|
68
|
+
i, n = 0, len(raw)
|
|
69
|
+
while i < n:
|
|
70
|
+
ch = raw[i]
|
|
71
|
+
if ch == "\\" and i + 1 < n:
|
|
72
|
+
nxt = raw[i + 1]
|
|
73
|
+
if nxt == "x" and i + 3 < n and raw[i + 2] in _HEX and raw[i + 3] in _HEX:
|
|
74
|
+
out.append(chr(int(raw[i + 2 : i + 4], 16)))
|
|
75
|
+
i += 4
|
|
76
|
+
continue
|
|
77
|
+
if nxt in _LOG_ESCAPES:
|
|
78
|
+
out.append(_LOG_ESCAPES[nxt])
|
|
79
|
+
i += 2
|
|
80
|
+
continue
|
|
81
|
+
out.append(ch)
|
|
82
|
+
i += 1
|
|
83
|
+
return "".join(out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _percent_decode(text: str, *, plus: bool) -> str:
|
|
87
|
+
"""Percent-decoding (``+`` is a space in query strings); invalid UTF-8 becomes U+FFFD."""
|
|
88
|
+
if "%" not in text and not (plus and "+" in text):
|
|
89
|
+
return text
|
|
90
|
+
data = bytearray()
|
|
91
|
+
i, n = 0, len(text)
|
|
92
|
+
while i < n:
|
|
93
|
+
ch = text[i]
|
|
94
|
+
if ch == "%" and i + 2 < n and text[i + 1] in _HEX and text[i + 2] in _HEX:
|
|
95
|
+
data.append(int(text[i + 1 : i + 3], 16))
|
|
96
|
+
i += 3
|
|
97
|
+
continue
|
|
98
|
+
data.extend(b" " if plus and ch == "+" else ch.encode("utf-8"))
|
|
99
|
+
i += 1
|
|
100
|
+
return data.decode("utf-8", errors="replace")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _kind(pointer: str) -> str:
|
|
104
|
+
"""How a field is encoded in the line, from its path."""
|
|
105
|
+
if "/query/" in pointer or pointer == "/request/path":
|
|
106
|
+
return "url"
|
|
107
|
+
if pointer == "/http_referer":
|
|
108
|
+
return "referer"
|
|
109
|
+
if pointer in ("/request/method", "/request/protocol"):
|
|
110
|
+
return "request_part"
|
|
111
|
+
if pointer in ("/request", "/http_user_agent"):
|
|
112
|
+
return "quoted"
|
|
113
|
+
if pointer == "/time_local":
|
|
114
|
+
return "time"
|
|
115
|
+
return "bare"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _decode(pointer: str, raw: str) -> str:
|
|
119
|
+
text = _log_unescape(raw)
|
|
120
|
+
if _kind(pointer) == "url":
|
|
121
|
+
return _percent_decode(text, plus="/query/" in pointer)
|
|
122
|
+
return text
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _hex_escape(ch: str) -> str:
|
|
126
|
+
return "".join(f"\\x{b:02X}" for b in ch.encode("utf-8"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _escape(owner: FieldToken, text: str) -> str:
|
|
130
|
+
kind = _kind(owner.pointer)
|
|
131
|
+
if kind == "url":
|
|
132
|
+
return "".join(
|
|
133
|
+
ch if ch in _URL_SAFE else "".join(f"%{b:02X}" for b in ch.encode("utf-8"))
|
|
134
|
+
for ch in text
|
|
135
|
+
)
|
|
136
|
+
special = {
|
|
137
|
+
"quoted": '"\\',
|
|
138
|
+
"referer": '"\\',
|
|
139
|
+
"request_part": '"\\ ',
|
|
140
|
+
"time": '"\\]',
|
|
141
|
+
"bare": '"\\',
|
|
142
|
+
}[kind]
|
|
143
|
+
out: list[str] = []
|
|
144
|
+
for ch in text:
|
|
145
|
+
if ch in special or ord(ch) < 0x20 or ord(ch) == 0x7F or (kind == "bare" and ch.isspace()):
|
|
146
|
+
out.append(_hex_escape(ch))
|
|
147
|
+
elif kind == "referer" and ch in "?#":
|
|
148
|
+
out.append(f"%{ord(ch):02X}")
|
|
149
|
+
else:
|
|
150
|
+
out.append(ch)
|
|
151
|
+
return "".join(out)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _unescape(owner: FieldToken, raw: str) -> Any:
|
|
155
|
+
return _decode(owner.pointer, raw)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _field(
|
|
159
|
+
tokens: list[FieldToken], line: str, pointer: str, key: str, start: int, end: int
|
|
160
|
+
) -> None:
|
|
161
|
+
raw = line[start:end]
|
|
162
|
+
if raw in ("", "-"):
|
|
163
|
+
return # "-" is the log's "no value"
|
|
164
|
+
value = _decode(pointer, raw)
|
|
165
|
+
tokens.append(FieldToken("string", start, end, pointer, key, value, value != raw))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _query(tokens: list[FieldToken], base: str, line: str, start: int, end: int) -> None:
|
|
169
|
+
"""``name=value&...`` between ``start`` and ``end`` as key and value tokens."""
|
|
170
|
+
i = start
|
|
171
|
+
while i <= end:
|
|
172
|
+
amp = line.find("&", i, end)
|
|
173
|
+
stop = end if amp < 0 else amp
|
|
174
|
+
if stop > i:
|
|
175
|
+
eq = line.find("=", i, stop)
|
|
176
|
+
key_end = stop if eq < 0 else eq
|
|
177
|
+
name_raw = line[i:key_end]
|
|
178
|
+
name = _percent_decode(_log_unescape(name_raw), plus=True)
|
|
179
|
+
pointer = f"{base}/query/{pointer_escape(name)}"
|
|
180
|
+
tokens.append(FieldToken("key", i, key_end, pointer, name, name, name != name_raw))
|
|
181
|
+
if eq >= 0:
|
|
182
|
+
raw = line[eq + 1 : stop]
|
|
183
|
+
value = _decode(pointer, raw)
|
|
184
|
+
tokens.append(
|
|
185
|
+
FieldToken("string", eq + 1, stop, pointer, name, value, value != raw)
|
|
186
|
+
)
|
|
187
|
+
i = stop + 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _target(tokens: list[FieldToken], line: str, start: int, end: int) -> None:
|
|
191
|
+
q = line.find("?", start, end)
|
|
192
|
+
_field(tokens, line, "/request/path", "path", start, end if q < 0 else q)
|
|
193
|
+
if q >= 0:
|
|
194
|
+
_query(tokens, "/request", line, q + 1, end)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_regions(line: str, max_depth: int = 64) -> list[FieldRegion]:
|
|
198
|
+
"""The access-log fields of ``line`` as one region, or ``[]`` if it is not in that layout."""
|
|
199
|
+
head = _HEAD_RE.match(line)
|
|
200
|
+
if head is None:
|
|
201
|
+
return []
|
|
202
|
+
request_end = _quoted(line, head.end())
|
|
203
|
+
if request_end is None:
|
|
204
|
+
return []
|
|
205
|
+
short = _SHORT_RE.match(line, request_end)
|
|
206
|
+
if short is None:
|
|
207
|
+
return []
|
|
208
|
+
tokens: list[FieldToken] = []
|
|
209
|
+
for group, name in enumerate(("remote_addr", "ident", "remote_user", "time_local"), 1):
|
|
210
|
+
_field(tokens, line, "/" + name, name, head.start(group), head.end(group))
|
|
211
|
+
req_start, req_end = head.end() + 1, request_end - 1
|
|
212
|
+
request = _REQUEST_RE.fullmatch(line, req_start, req_end)
|
|
213
|
+
if request is None:
|
|
214
|
+
_field(tokens, line, "/request", "request", req_start, req_end)
|
|
215
|
+
else:
|
|
216
|
+
_field(tokens, line, "/request/method", "method", request.start(1), request.end(1))
|
|
217
|
+
_target(tokens, line, request.start(2), request.end(2))
|
|
218
|
+
_field(tokens, line, "/request/protocol", "protocol", request.start(3), request.end(3))
|
|
219
|
+
_field(tokens, line, "/status", "status", short.start(1), short.end(1))
|
|
220
|
+
_field(tokens, line, "/body_bytes_sent", "body_bytes_sent", short.start(2), short.end(2))
|
|
221
|
+
end = short.end()
|
|
222
|
+
referer_end = _quoted(line, end + 1) if line.startswith(' "', end) else None
|
|
223
|
+
if referer_end is not None:
|
|
224
|
+
agent_end = _quoted(line, referer_end + 1) if line.startswith(' "', referer_end) else None
|
|
225
|
+
if agent_end is not None:
|
|
226
|
+
ref_start, ref_stop = end + 2, referer_end - 1
|
|
227
|
+
q = line.find("?", ref_start, ref_stop)
|
|
228
|
+
_field(
|
|
229
|
+
tokens, line, "/http_referer", "http_referer", ref_start, ref_stop if q < 0 else q
|
|
230
|
+
)
|
|
231
|
+
if q >= 0:
|
|
232
|
+
_query(tokens, "/http_referer", line, q + 1, ref_stop)
|
|
233
|
+
_field(
|
|
234
|
+
tokens, line, "/http_user_agent", "http_user_agent", referer_end + 2, agent_end - 1
|
|
235
|
+
)
|
|
236
|
+
end = agent_end
|
|
237
|
+
return [FieldRegion(0, end, tuple(tokens))]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
ACCESSLOG_STRUCTURE = LineStructure(parse=parse_regions, unescape=_unescape, escape=_escape)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class AccessLogReader(TextReader):
|
|
244
|
+
format = InputFormat.ACCESSLOG
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Format adapter contracts shared by all readers/writers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Any, Protocol
|
|
10
|
+
|
|
11
|
+
from ciphergate.config import RuntimeSettings
|
|
12
|
+
from ciphergate.constants import InputFormat
|
|
13
|
+
from ciphergate.errors import InputFormatError, InputLimitError, UsageError
|
|
14
|
+
from ciphergate.models.finding import ScanItem
|
|
15
|
+
from ciphergate.models.policy import PolicySettings
|
|
16
|
+
|
|
17
|
+
# Readers split lines on bytes, so only ASCII-compatible encodings are supported.
|
|
18
|
+
SUPPORTED_ENCODINGS = frozenset({"utf-8", "utf-8-sig", "ascii", "latin-1", "cp1252"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_encoding(name: str) -> str:
|
|
22
|
+
try:
|
|
23
|
+
canonical = codecs.lookup(name).name
|
|
24
|
+
except LookupError:
|
|
25
|
+
raise UsageError("unknown encoding", code="unsupported_encoding") from None
|
|
26
|
+
aliases = {
|
|
27
|
+
"utf-8": "utf-8",
|
|
28
|
+
"utf-8-sig": "utf-8-sig",
|
|
29
|
+
"ascii": "ascii",
|
|
30
|
+
"iso8859-1": "latin-1",
|
|
31
|
+
"latin-1": "latin-1",
|
|
32
|
+
"cp1252": "cp1252",
|
|
33
|
+
}
|
|
34
|
+
if canonical not in aliases:
|
|
35
|
+
raise UsageError(
|
|
36
|
+
"unsupported encoding; supported: " + ", ".join(sorted(SUPPORTED_ENCODINGS)),
|
|
37
|
+
code="unsupported_encoding",
|
|
38
|
+
)
|
|
39
|
+
return aliases[canonical]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class Record:
|
|
44
|
+
index: int
|
|
45
|
+
line: int | None
|
|
46
|
+
data: Any
|
|
47
|
+
line_ending: str = ""
|
|
48
|
+
malformed: bool = False
|
|
49
|
+
error_code: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class ReadStats:
|
|
54
|
+
bytes_read: int = 0
|
|
55
|
+
records: int = 0
|
|
56
|
+
blank_lines: int = 0
|
|
57
|
+
parse_errors: list[tuple[int | None, str]] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Reader(Protocol):
|
|
61
|
+
format: InputFormat
|
|
62
|
+
stats: ReadStats
|
|
63
|
+
|
|
64
|
+
def records(self) -> Iterator[Record]: ...
|
|
65
|
+
|
|
66
|
+
def iter_items(self, record: Record) -> Iterator[ScanItem]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Writer(Protocol):
|
|
70
|
+
def write_record(self, record: Record, data: Any) -> None: ...
|
|
71
|
+
|
|
72
|
+
def close(self) -> None: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class ReaderOptions:
|
|
77
|
+
runtime: RuntimeSettings
|
|
78
|
+
policy_settings: PolicySettings
|
|
79
|
+
encoding: str = "utf-8"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_bounded_lines(
|
|
83
|
+
stream: IO[bytes], options: ReaderOptions, stats: ReadStats
|
|
84
|
+
) -> Iterator[tuple[int, bytes]]:
|
|
85
|
+
"""Yield (line_number, raw line bytes incl. terminator) enforcing per-line and total limits."""
|
|
86
|
+
max_line = options.runtime.max_record_bytes
|
|
87
|
+
line_no = 0
|
|
88
|
+
while True:
|
|
89
|
+
raw = stream.readline(max_line + 2)
|
|
90
|
+
if not raw:
|
|
91
|
+
return
|
|
92
|
+
line_no += 1
|
|
93
|
+
stats.bytes_read += len(raw)
|
|
94
|
+
if stats.bytes_read > options.runtime.max_input_bytes:
|
|
95
|
+
raise InputLimitError("input exceeds max_input_bytes", code="size_limit_exceeded")
|
|
96
|
+
content_len = len(raw.rstrip(b"\r\n"))
|
|
97
|
+
if content_len > max_line:
|
|
98
|
+
raise InputLimitError(
|
|
99
|
+
f"line {line_no} exceeds max_record_bytes ({max_line})", code="record_too_large"
|
|
100
|
+
)
|
|
101
|
+
yield line_no, raw
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def split_line_ending(text: str) -> tuple[str, str]:
|
|
105
|
+
if text.endswith("\r\n"):
|
|
106
|
+
return text[:-2], "\r\n"
|
|
107
|
+
if text.endswith("\n") or text.endswith("\r"):
|
|
108
|
+
return text[:-1], text[-1]
|
|
109
|
+
return text, ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_line(raw: bytes, encoding: str, line_no: int, *, first: bool) -> str:
|
|
113
|
+
if first and raw.startswith(codecs.BOM_UTF8) and encoding.startswith("utf-8"):
|
|
114
|
+
raw = raw[len(codecs.BOM_UTF8) :]
|
|
115
|
+
try:
|
|
116
|
+
return raw.decode(encoding.replace("-sig", ""))
|
|
117
|
+
except UnicodeDecodeError:
|
|
118
|
+
raise InputFormatError(
|
|
119
|
+
f"line {line_no} is not valid {encoding}", code="decode_error"
|
|
120
|
+
) from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def has_utf8_bom(path: Path) -> bool:
|
|
124
|
+
with path.open("rb") as fh:
|
|
125
|
+
return fh.read(3) == codecs.BOM_UTF8
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def detect_newline(path: Path) -> str:
|
|
129
|
+
with path.open("rb") as fh:
|
|
130
|
+
chunk = fh.read(64 * 1024)
|
|
131
|
+
return "\r\n" if b"\r\n" in chunk else "\n"
|