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,125 @@
1
+ """Shared span machinery for structured line formats (JSON-in-log, access logs).
2
+
3
+ A structured line format parses a text line into *regions* (spans of recognised structure) made
4
+ of *tokens* (keys and values with raw offsets). The pipeline scans the line exactly like
5
+ ``text`` and uses the parse only to add field context, to snap findings onto token boundaries,
6
+ and to re-encode replacements so that the structure survives rewriting.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class FieldToken:
18
+ """A key or value inside a parsed region. Spans are raw offsets in the line."""
19
+
20
+ kind: str # "key", "string" (value) or "literal" (a value replaced as a whole)
21
+ start: int # strings: the raw value body (inside any quotes); literals: the literal itself
22
+ end: int
23
+ pointer: str # JSON Pointer-style field path (keys: the pointer of their value)
24
+ key: str | None # nearest field name, None when the value has no name
25
+ value: Any # decoded value (keys: the decoded key)
26
+ escaped: bool = False # raw text differs from the decoded value
27
+ style: str = "" # format-specific encoding of the token in the line (for escape/unescape)
28
+ hint: str | None = None # canonical field name for field-name heuristics (e.g. CEF "suser")
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class FieldRegion:
33
+ start: int
34
+ end: int
35
+ tokens: tuple[FieldToken, ...]
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class LineStructure:
40
+ """How one structured line format parses and re-encodes its tokens."""
41
+
42
+ parse: Callable[[str, int], list[FieldRegion]] # (line, max_depth) -> regions
43
+ unescape: Callable[[FieldToken, str], Any] # raw part of a token -> what a transform sees
44
+ escape: Callable[[FieldToken, str], str] # replacement text -> raw text for the token
45
+
46
+
47
+ def region_tokens(regions: list[FieldRegion]) -> list[FieldToken]:
48
+ return [t for r in regions for t in r.tokens]
49
+
50
+
51
+ def token_at(tokens: list[FieldToken], start: int, end: int) -> FieldToken | None:
52
+ """The token whose span contains ``[start, end)`` (literals: any overlap), if any."""
53
+ for t in tokens:
54
+ if t.kind == "literal" and start < t.end and t.start < end:
55
+ return t
56
+ if t.kind != "literal" and t.start <= start and end <= t.end:
57
+ return t
58
+ return None
59
+
60
+
61
+ def snap_span(regions: list[FieldRegion], start: int, end: int) -> list[tuple[int, int]]:
62
+ """Split a span so that no piece crosses structural syntax.
63
+
64
+ Parts outside every region are kept. Inside a region, the span becomes the spans of the
65
+ value tokens it overlaps (or key tokens, if it overlaps no value); literal tokens are taken
66
+ whole. A span over punctuation only covers every value of its region, whole; a region with
67
+ no values yields nothing. A span already inside one string token, or covering one literal
68
+ exactly, is returned unchanged.
69
+ """
70
+ pieces: list[tuple[int, int]] = []
71
+ cursor = start
72
+ for region in regions:
73
+ if region.end <= start or end <= region.start:
74
+ continue
75
+ if cursor < region.start:
76
+ pieces.append((cursor, region.start))
77
+ lo, hi = max(start, region.start), min(end, region.end)
78
+ inside = [t for t in region.tokens if t.kind != "key" and lo < t.end and t.start < hi]
79
+ if not inside:
80
+ inside = [t for t in region.tokens if t.kind == "key" and lo < t.end and t.start < hi]
81
+ if not inside:
82
+ # Only punctuation (e.g. a secret pattern that matched an opening brace): the
83
+ # finding is about the structure itself, so protect every value in it, whole.
84
+ lo, hi = region.start, region.end
85
+ inside = [t for t in region.tokens if t.kind != "key"]
86
+ for t in inside:
87
+ if t.kind == "literal":
88
+ pieces.append((t.start, t.end))
89
+ else:
90
+ pieces.append((max(lo, t.start), min(hi, t.end)))
91
+ cursor = region.end
92
+ if cursor < end:
93
+ pieces.append((cursor, end))
94
+ return [p for p in pieces if p[0] < p[1]]
95
+
96
+
97
+ class SpanCodec:
98
+ """Decodes a span to the value a transform sees and re-encodes the replacement."""
99
+
100
+ def __init__(self, structure: LineStructure, regions: list[FieldRegion]) -> None:
101
+ self.structure = structure
102
+ self.tokens = region_tokens(regions)
103
+
104
+ def decode(self, start: int, end: int, raw: str) -> Any:
105
+ owner = token_at(self.tokens, start, end)
106
+ if owner is None:
107
+ return raw
108
+ if owner.kind == "literal" or (start, end) == (owner.start, owner.end):
109
+ return owner.value
110
+ return self.structure.unescape(owner, raw)
111
+
112
+ def encode(self, start: int, end: int, replacement: Any) -> str:
113
+ owner = token_at(self.tokens, start, end)
114
+ text = replacement if isinstance(replacement, str) else str(replacement)
115
+ if owner is None:
116
+ return text
117
+ return self.structure.escape(owner, text)
118
+
119
+
120
+ def structure_still_valid(
121
+ structure: LineStructure, line: str, before: list[FieldRegion], max_depth: int
122
+ ) -> bool:
123
+ """After rewriting, the line must parse into at least as many regions and tokens."""
124
+ after = structure.parse(line, max_depth)
125
+ return len(after) >= len(before) and len(region_tokens(after)) >= len(region_tokens(before))
@@ -0,0 +1,100 @@
1
+ """logfmt adapter: text/log lines with ``key=value`` fields (``level=info user_email=a@b``).
2
+
3
+ Detection never relies on the parse alone. Every line is scanned exactly like ``text`` (including
4
+ multi-line private key blocks); the parse only adds field context on top:
5
+
6
+ * findings whose span lies inside a field value are attributed to that field (``field_name`` =
7
+ key, ``field_path`` = ``/<key>``), so policy ``field_names``/``field_paths`` rules apply;
8
+ * field-name heuristics and policy classifications run on each key and, when they match, cover
9
+ the whole value span.
10
+
11
+ Grammar: whitespace-separated tokens; a field is ``key=value`` with ``key`` matching
12
+ ``[A-Za-z_][A-Za-z0-9_.-]*`` and ``value`` either bare (up to whitespace, may be empty) or
13
+ double-quoted with backslash escapes. Other tokens (timestamps, words) are free text. A line
14
+ with an unterminated quote gets no field context (it is still scanned as text). Lines inside a
15
+ PEM private key block are never parsed. Output keeps the original line; only finding spans are
16
+ replaced, so quoting is preserved. ``drop_field`` is not supported (it becomes ``review``).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+
24
+ from ciphergate.constants import InputFormat
25
+ from ciphergate.readers.text_reader import TextReader
26
+
27
+ _KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.\-]{0,255}")
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class LogfmtField:
32
+ key: str
33
+ value: str # unquoted/unescaped
34
+ start: int # raw value span in the line (inside the quotes for quoted values)
35
+ end: int
36
+
37
+
38
+ def parse_fields(line: str) -> list[LogfmtField] | None:
39
+ """Fields of one line, or ``None`` when quoting is malformed (no field context then)."""
40
+ fields: list[LogfmtField] = []
41
+ i, n = 0, len(line)
42
+ while i < n:
43
+ if line[i].isspace():
44
+ i += 1
45
+ continue
46
+ key_match = _KEY_RE.match(line, i)
47
+ eq = key_match.end() if key_match else i
48
+ if key_match and eq < n and line[eq] == "=":
49
+ v = eq + 1
50
+ if v < n and line[v] == '"':
51
+ parsed = _quoted(line, v)
52
+ if parsed is None:
53
+ return None
54
+ value, end = parsed
55
+ if end < n and not line[end].isspace():
56
+ return None # text glued to a closing quote: ambiguous
57
+ fields.append(LogfmtField(key_match.group(), value, v + 1, end - 1))
58
+ i = end
59
+ continue
60
+ end = v
61
+ while end < n and not line[end].isspace():
62
+ end += 1
63
+ fields.append(LogfmtField(key_match.group(), line[v:end], v, end))
64
+ i = end
65
+ continue
66
+ while i < n and not line[i].isspace(): # free-text token
67
+ if line[i] == '"':
68
+ return None
69
+ i += 1
70
+ return fields
71
+
72
+
73
+ def _quoted(line: str, start: int) -> tuple[str, int] | None:
74
+ """Parse a quoted value starting at ``start`` (the opening quote). Returns (value, end)."""
75
+ out: list[str] = []
76
+ i = start + 1
77
+ n = len(line)
78
+ while i < n:
79
+ ch = line[i]
80
+ if ch == "\\" and i + 1 < n:
81
+ nxt = line[i + 1]
82
+ out.append({"n": "\n", "t": "\t", "r": "\r"}.get(nxt, nxt))
83
+ i += 2
84
+ continue
85
+ if ch == '"':
86
+ return "".join(out), i + 1
87
+ out.append(ch)
88
+ i += 1
89
+ return None
90
+
91
+
92
+ def field_for_span(fields: list[LogfmtField], span: tuple[int, int]) -> LogfmtField | None:
93
+ for f in fields:
94
+ if f.start <= span[0] and span[1] <= f.end:
95
+ return f
96
+ return None
97
+
98
+
99
+ class LogfmtReader(TextReader):
100
+ format = InputFormat.LOGFMT
@@ -0,0 +1,103 @@
1
+ """Stack-trace-aware log input (``--format stacktrace``).
2
+
3
+ Lines are read and scanned exactly like ``text``. On top of that, a small cross-line state machine
4
+ recognises stack trace lines and tells the analyzer what each one is:
5
+
6
+ * Python: ``Traceback (most recent call last):``, then ``File "...", line N, in f`` frames,
7
+ indented source/caret lines under a frame, and a final ``Type: message`` exception line;
8
+ * JVM: ``at pkg.Class.method(File.java:12)`` frames, ``Caused by:`` / ``Suppressed:`` lines and
9
+ ``... N more`` markers;
10
+ * Node.js: ``at fn (file.js:1:2)`` and ``at file.js:1:2`` frames.
11
+
12
+ There is no lookahead and nothing is buffered: each line is classified when it is read, from the
13
+ lines before it, so every line keeps the ``max_record_bytes`` limit and output is still written
14
+ line by line. Frame lines are recognised on their own, without a header, so a truncated or
15
+ interleaved trace still has every frame classified.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+
23
+ from ciphergate.constants import InputFormat
24
+ from ciphergate.readers.text_reader import TextReader
25
+
26
+ _PY_START_RE = re.compile(r"[ \t]*Traceback \(most recent call last\):[ \t]*")
27
+ _PY_FRAME_RE = re.compile(r'[ \t]+(File "[^"\r\n]{1,4096}", line \d{1,9}(?:, in [^\r\n]{1,4096})?)')
28
+ _PY_EXCEPTION_RE = re.compile(r"[A-Za-z_][\w.]{0,255}(?::[^\r\n]*)?")
29
+ # JVM ("at a.B.c(B.java:1)", logback "~[app.jar:1.0]") and Node ("at fn (x.js:1:2)", "at x.js:1:2").
30
+ _FRAME_RE = re.compile(
31
+ r"[ \t]+(at (?:[^()\r\n]{1,4096}\([^()\r\n]{0,4096}\)(?: ~?\[[^\[\]\r\n]{0,1024}\])?"
32
+ r"|(?:async )?[^\s()]{1,4096}:\d{1,9}:\d{1,9}))"
33
+ )
34
+ _NODE_LOCATION_RE = re.compile(r":\d{1,9}:\d{1,9}\)?$") # JVM frames carry "File.java:12"
35
+ _CAUSE_RE = re.compile(r"[ \t]*(Caused by|Suppressed): [^\r\n]*")
36
+ _MORE_RE = re.compile(r"[ \t]+\.\.\. \d{1,9} (?:more|common frames omitted)")
37
+
38
+ FRAME = "frame"
39
+ SOURCE = "source" # a Python source or caret line under a frame
40
+ EXCEPTION = "exception"
41
+ CONTEXT = "context" # header and "... N more" lines: part of a trace, no content of their own
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class TraceLine:
46
+ role: str
47
+ pointer: str
48
+ span: tuple[int, int] | None = None # FRAME/SOURCE: the part to protect (after indentation)
49
+ family: str = ""
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class TraceState:
54
+ """What the previous lines said: inside a Python traceback, or a run of JVM/Node frames."""
55
+
56
+ kind: str | None = None # "python" or "frames"
57
+ frames: int = 0
58
+
59
+ def reset(self, kind: str | None = None) -> None:
60
+ self.kind, self.frames = kind, 0
61
+
62
+ def frame(self, family: str, span: tuple[int, int]) -> TraceLine:
63
+ line = TraceLine(FRAME, f"/stack/frames/{self.frames}", span, family)
64
+ self.frames += 1
65
+ return line
66
+
67
+
68
+ def classify(text: str, state: TraceState) -> TraceLine | None:
69
+ """Classify one line (without its line ending) and advance ``state``."""
70
+ if _PY_START_RE.fullmatch(text):
71
+ state.reset("python")
72
+ return TraceLine(CONTEXT, "/stack")
73
+ if m := _PY_FRAME_RE.fullmatch(text):
74
+ if state.kind != "python":
75
+ state.reset("python") # a frame without its header (e.g. a prefixed first line)
76
+ return state.frame("python_frame", m.span(1))
77
+ if state.kind == "python":
78
+ stripped = text.lstrip(" \t")
79
+ if stripped and stripped != text:
80
+ last = state.frames - 1
81
+ pointer = f"/stack/frames/{last}/source" if state.frames else "/stack/source"
82
+ span = (len(text) - len(stripped), len(text))
83
+ return TraceLine(SOURCE, pointer, span, "python_source")
84
+ state.reset()
85
+ if stripped and _PY_EXCEPTION_RE.fullmatch(text):
86
+ return TraceLine(EXCEPTION, "/stack/exception")
87
+ return None
88
+ if m := _FRAME_RE.fullmatch(text):
89
+ if state.kind != "frames":
90
+ state.reset("frames")
91
+ family = "node_frame" if _NODE_LOCATION_RE.search(text) else "jvm_frame"
92
+ return state.frame(family, m.span(1))
93
+ if state.kind == "frames":
94
+ if m := _CAUSE_RE.fullmatch(text):
95
+ return TraceLine(EXCEPTION, "/stack/" + m.group(1).lower().replace(" ", "_"))
96
+ if _MORE_RE.fullmatch(text):
97
+ return TraceLine(CONTEXT, "/stack")
98
+ state.reset()
99
+ return None
100
+
101
+
102
+ class StackTraceReader(TextReader):
103
+ format = InputFormat.STACKTRACE
@@ -0,0 +1,309 @@
1
+ """syslog adapter: RFC 5424 and RFC 3164 lines, and CEF (bare or as a syslog message).
2
+
3
+ * RFC 5424: ``<34>1 2026-01-05T10:00:00Z host app 42 ID7 [origin ip="192.0.2.1"] message``.
4
+ Header fields become ``/timestamp``, ``/hostname``, ``/app_name``, ``/procid``, ``/msgid``;
5
+ structured-data parameters become ``/sd/<sd-id>/<name>`` (escapes ``\\"``, ``\\\\``, ``\\]``);
6
+ the message is ``/msg``.
7
+ * RFC 3164 / BSD, including files written by rsyslog without ``<PRI>`` and with RFC 3339
8
+ timestamps: ``Jan 5 10:00:00 host sshd[42]: message`` gives ``/timestamp``, ``/hostname``,
9
+ ``/app_name`` (the tag), ``/procid`` and ``/msg``. A line needs a ``tag:`` (or a CEF message)
10
+ to be recognised.
11
+ * CEF: ``CEF:0|Vendor|Product|1.0|100|Name|5|suser=alice src=192.0.2.1``, on its own or as
12
+ the message of either syslog layout. Header fields become ``/cef/device_vendor`` ...
13
+ ``/cef/severity``, with CEF ``Name`` as ``/cef/event_name`` (escapes ``\\|``, ``\\\\``);
14
+ extensions become ``/cef/extension/<key>``
15
+ (escapes ``\\=``, ``\\\\``, ``\\n``, ``\\r``). Common short keys get a canonical name for the
16
+ field-name heuristics (``suser`` -> ``source_user_name``, ``src`` -> ``source_ip``), and
17
+ ``csN``/``cnN`` fields use their ``csNLabel`` value; the raw key stays the field name.
18
+
19
+ Detection never relies on the parse alone: every line is scanned exactly like ``text`` and the
20
+ parse only adds field context. Replacements are re-escaped for their field; header fields get
21
+ ``_`` for whitespace (an RFC 3164 tag also for anything outside ``[A-Za-z0-9_./-]``). Every
22
+ rewritten line must still parse with as many fields, or the run fails. ``-`` (NILVALUE) fields
23
+ get no field context. ``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
+ _PRI = r"<\d{1,3}>"
37
+ _RFC5424_RE = re.compile(
38
+ _PRI + r"[1-9]\d{0,2} (\S{1,64}) (\S{1,255}) (\S{1,48}) (\S{1,128}) (\S{1,32}) "
39
+ )
40
+ _TS3164 = r"[A-Z][a-z]{2} {1,2}\d{1,2} \d{2}:\d{2}:\d{2}"
41
+ _TS3339 = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})"
42
+ _RFC3164_RE = re.compile(rf"(?:{_PRI})?({_TS3164}|{_TS3339}) (\S{{1,255}}) ")
43
+ _TAG_RE = re.compile(r"([A-Za-z0-9_./-]{1,48})(?:\[([0-9A-Za-z_-]{1,32})\])?: ")
44
+ _SD_NAME_RE = re.compile(r'[^\s="\]]{1,32}')
45
+ _CEF_START = "CEF:"
46
+ _CEF_VERSION_RE = re.compile(r"CEF:\d{1,2}(?:\.\d{1,2})?\|")
47
+ _CEF_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}=")
48
+ _CEF_HEADERS = (
49
+ "device_vendor",
50
+ "device_product",
51
+ "device_version",
52
+ "signature_id",
53
+ "event_name", # CEF "Name"; not "name", which the heuristics read as a person name
54
+ "severity",
55
+ )
56
+ _MAX_SD_ELEMENTS = 64
57
+ _MAX_SD_PARAMS = 64
58
+ _MAX_CEF_FIELDS = 256
59
+ _MAX_VALUE = 16384
60
+
61
+ # Canonical names for common CEF extension keys, used only for field-name heuristics.
62
+ CEF_HINTS = {
63
+ "src": "source_ip",
64
+ "dst": "destination_ip",
65
+ "dvc": "device_ip",
66
+ "c6a1": "device_ipv6",
67
+ "c6a2": "source_ipv6",
68
+ "c6a3": "destination_ipv6",
69
+ "shost": "source_hostname",
70
+ "dhost": "destination_hostname",
71
+ "dvchost": "device_hostname",
72
+ "suser": "source_user_name",
73
+ "duser": "destination_user_name",
74
+ "suid": "source_user_id",
75
+ "duid": "destination_user_id",
76
+ "smac": "source_mac_address",
77
+ "dmac": "destination_mac_address",
78
+ "dvcmac": "device_mac_address",
79
+ }
80
+ _LABELLED = re.compile(r"(?:cs|cn|cfp|c6a|flexString|flexNumber|flexDate)\d{1,2}")
81
+
82
+ # Characters an RFC 3164 tag may hold (other header fields only exclude whitespace).
83
+ _TAG_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_./-")
84
+
85
+
86
+ def _unescape_chars(raw: str, escapes: dict[str, str]) -> str:
87
+ if "\\" not in raw:
88
+ return raw
89
+ out: list[str] = []
90
+ i, n = 0, len(raw)
91
+ while i < n:
92
+ ch = raw[i]
93
+ if ch == "\\" and i + 1 < n and raw[i + 1] in escapes:
94
+ out.append(escapes[raw[i + 1]])
95
+ i += 2
96
+ continue
97
+ out.append(ch)
98
+ i += 1
99
+ return "".join(out)
100
+
101
+
102
+ _UNESCAPES = {
103
+ "sd": {'"': '"', "\\": "\\", "]": "]"},
104
+ "cef_header": {"|": "|", "\\": "\\"},
105
+ "cef_ext": {"=": "=", "\\": "\\", "n": "\n", "r": "\r"},
106
+ }
107
+
108
+
109
+ def _decode(style: str, raw: str) -> str:
110
+ escapes = _UNESCAPES.get(style)
111
+ return _unescape_chars(raw, escapes) if escapes else raw
112
+
113
+
114
+ def _escape(owner: FieldToken, text: str) -> str:
115
+ style = owner.style
116
+ if style == "sd":
117
+ return "".join("\\" + ch if ch in '"\\]' else _no_control(ch) for ch in text)
118
+ if style == "cef_header":
119
+ return "".join("\\" + ch if ch in "|\\" else _no_control(ch) for ch in text)
120
+ if style == "cef_ext":
121
+ table = {"\\": "\\\\", "=": "\\=", "\n": "\\n", "\r": "\\r"}
122
+ return "".join(table.get(ch, _no_control(ch)) for ch in text)
123
+ if style == "tag":
124
+ return "".join(ch if ch in _TAG_SAFE else "_" for ch in text) or "_"
125
+ if style == "bare":
126
+ return "".join("_" if ch.isspace() else _no_control(ch) for ch in text) or "_"
127
+ return "".join(_no_control(ch) for ch in text) # free-text message
128
+
129
+
130
+ def _no_control(ch: str) -> str:
131
+ return " " if ord(ch) < 0x20 or ord(ch) == 0x7F else ch
132
+
133
+
134
+ def _unescape(owner: FieldToken, raw: str) -> Any:
135
+ return _decode(owner.style, raw)
136
+
137
+
138
+ class _Tokens:
139
+ def __init__(self, line: str) -> None:
140
+ self.line = line
141
+ self.items: list[FieldToken] = []
142
+
143
+ def value(
144
+ self,
145
+ pointer: str,
146
+ key: str,
147
+ start: int,
148
+ end: int,
149
+ style: str,
150
+ hint: str | None = None,
151
+ *,
152
+ nil: bool = True,
153
+ ) -> None:
154
+ raw = self.line[start:end]
155
+ if raw == "" or (nil and raw == "-"):
156
+ return # NILVALUE / empty: nothing to protect
157
+ value = _decode(style, raw)
158
+ self.items.append(
159
+ FieldToken("string", start, end, pointer, key, value, value != raw, style, hint)
160
+ )
161
+
162
+ def key(self, pointer: str, name: str, start: int, end: int) -> None:
163
+ self.items.append(FieldToken("key", start, end, pointer, name, name))
164
+
165
+
166
+ def _structured_data(t: _Tokens, i: int) -> int | None:
167
+ """Parse STRUCTURED-DATA at ``i``; returns its end, or ``None`` when malformed."""
168
+ line, n = t.line, len(t.line)
169
+ if line.startswith("-", i) and (i + 1 == n or line[i + 1] == " "):
170
+ return i + 1
171
+ elements = 0
172
+ while i < n and line[i] == "[":
173
+ elements += 1
174
+ if elements > _MAX_SD_ELEMENTS:
175
+ return None
176
+ sd_id = _SD_NAME_RE.match(line, i + 1)
177
+ if sd_id is None:
178
+ return None
179
+ base = f"/sd/{pointer_escape(sd_id.group())}"
180
+ i = sd_id.end()
181
+ params = 0
182
+ while i < n and line[i] == " ":
183
+ params += 1
184
+ name = _SD_NAME_RE.match(line, i + 1)
185
+ if params > _MAX_SD_PARAMS or name is None:
186
+ return None
187
+ eq = name.end()
188
+ if not line.startswith('="', eq):
189
+ return None
190
+ j, limit = eq + 2, min(n, eq + 2 + _MAX_VALUE)
191
+ while j < limit and line[j] != '"':
192
+ j += 2 if line[j] == "\\" else 1
193
+ if j >= limit:
194
+ return None
195
+ pointer = f"{base}/{pointer_escape(name.group())}"
196
+ t.key(pointer, name.group(), name.start(), name.end())
197
+ t.value(pointer, name.group(), eq + 2, j, "sd", nil=False)
198
+ i = j + 1
199
+ if i >= n or line[i] != "]":
200
+ return None
201
+ i += 1
202
+ return i if elements else None
203
+
204
+
205
+ def _cef(t: _Tokens, start: int) -> int | None:
206
+ """Parse a CEF record starting at ``start``; returns its end, or ``None`` if it is not CEF."""
207
+ line, n = t.line, len(t.line)
208
+ head = _CEF_VERSION_RE.match(line, start)
209
+ if head is None:
210
+ return None
211
+ i = head.end()
212
+ for name in _CEF_HEADERS:
213
+ j, limit = i, min(n, i + _MAX_VALUE)
214
+ while j < limit and line[j] != "|":
215
+ j += 2 if line[j] == "\\" else 1
216
+ if j >= limit:
217
+ return None
218
+ t.value(f"/cef/{name}", name, i, j, "cef_header", nil=False)
219
+ i = j + 1
220
+ _cef_extension(t, i, n)
221
+ return n
222
+
223
+
224
+ def _cef_extension(t: _Tokens, start: int, end: int) -> None:
225
+ line = t.line
226
+ # A key starts the extension or follows a space; "=" inside values must be escaped.
227
+ keys: list[tuple[int, int]] = [] # (key start, value start)
228
+ i = start
229
+ while i < end and len(keys) < _MAX_CEF_FIELDS:
230
+ if i == start or line[i - 1] == " ":
231
+ m = _CEF_KEY_RE.match(line, i, end)
232
+ if m is not None:
233
+ keys.append((i, m.end()))
234
+ i = m.end()
235
+ continue
236
+ i += 1
237
+ labels: dict[str, str] = {}
238
+ spans: list[tuple[str, int, int]] = []
239
+ for index, (key_start, value_start) in enumerate(keys):
240
+ value_end = keys[index + 1][0] - 1 if index + 1 < len(keys) else end
241
+ while value_end > value_start and line[value_end - 1] == " ":
242
+ value_end -= 1
243
+ name = line[key_start : value_start - 1]
244
+ spans.append((name, value_start, value_end))
245
+ if name.endswith("Label"):
246
+ labels[name[: -len("Label")]] = _decode("cef_ext", line[value_start:value_end])
247
+ for name, value_start, value_end in spans:
248
+ hint = CEF_HINTS.get(name)
249
+ if hint is None and _LABELLED.fullmatch(name):
250
+ hint = labels.get(name)
251
+ pointer = f"/cef/extension/{pointer_escape(name)}"
252
+ t.key(pointer, name, value_start - len(name) - 1, value_start - 1)
253
+ t.value(pointer, name, value_start, value_end, "cef_ext", hint, nil=False)
254
+
255
+
256
+ def _message(t: _Tokens, start: int) -> None:
257
+ """A syslog MSG: CEF if it is one, else free text."""
258
+ line = t.line
259
+ if line.startswith("", start):
260
+ start += 1
261
+ if line.startswith(_CEF_START, start) and _cef(t, start) is not None:
262
+ return
263
+ t.value("/msg", "msg", start, len(line), "msg", nil=False)
264
+
265
+
266
+ def parse_regions(line: str, max_depth: int = 64) -> list[FieldRegion]:
267
+ """The syslog/CEF fields of ``line`` as one region, or ``[]`` for other layouts."""
268
+ t = _Tokens(line)
269
+ if line.startswith(_CEF_START):
270
+ if _cef(t, 0) is None:
271
+ return []
272
+ return [FieldRegion(0, len(line), tuple(t.items))]
273
+ m = _RFC5424_RE.match(line)
274
+ if m is not None:
275
+ for group, name in enumerate(("timestamp", "hostname", "app_name", "procid", "msgid"), 1):
276
+ t.value("/" + name, name, m.start(group), m.end(group), "bare")
277
+ end = _structured_data(t, m.end())
278
+ if end is None:
279
+ return []
280
+ if end < len(line):
281
+ if line[end] != " ":
282
+ return []
283
+ _message(t, end + 1)
284
+ return [FieldRegion(0, len(line), tuple(t.items))]
285
+ m = _RFC3164_RE.match(line)
286
+ if m is None:
287
+ return []
288
+ t.value("/timestamp", "timestamp", m.start(1), m.end(1), "bare")
289
+ t.value("/hostname", "hostname", m.start(2), m.end(2), "bare")
290
+ rest = m.end()
291
+ if line.startswith(_CEF_START, rest):
292
+ if _cef(t, rest) is None:
293
+ return []
294
+ return [FieldRegion(0, len(line), tuple(t.items))]
295
+ tag = _TAG_RE.match(line, rest)
296
+ if tag is None:
297
+ return []
298
+ t.value("/app_name", "app_name", tag.start(1), tag.end(1), "tag")
299
+ if tag.group(2) is not None:
300
+ t.value("/procid", "procid", tag.start(2), tag.end(2), "tag")
301
+ _message(t, tag.end())
302
+ return [FieldRegion(0, len(line), tuple(t.items))]
303
+
304
+
305
+ SYSLOG_STRUCTURE = LineStructure(parse=parse_regions, unescape=_unescape, escape=_escape)
306
+
307
+
308
+ class SyslogReader(TextReader):
309
+ format = InputFormat.SYSLOG