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,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
|