Skip to content

Commit 1c40208

Browse files
[processor/transform/internal/logparsingfuncs] Add ParseCEF log parsing function (#49288)
1 parent 502bcb9 commit 1c40208

6 files changed

Lines changed: 1033 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog)
7+
component: processor/transform
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Add `ParseCEF` function for parsing Common Event Format (CEF) security event data.
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [48351]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user]

processor/transformprocessor/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ In addition to the common OTTL functions, the processor defines its own function
275275

276276
**Logs only functions**
277277

278+
- [ParseCEF](#parsecef)
278279
- [ParseCLF](#parseclf)
279280
- [ParseLEEF](#parseleef)
280281

@@ -711,6 +712,32 @@ Examples:
711712
# counts: [84, 126, 5, 50, 1]
712713
```
713714

715+
### ParseCEF
716+
717+
`ParseCEF(target)`
718+
719+
The `ParseCEF` function returns a `pcommon.Map` that is the result of parsing the `target` string as a [Common Event Format (CEF)](https://www.microfocus.com/documentation/arcsight/arcsight-smartconnectors-8.4/cef-implementation-standard/Content/CEF/Chapter%201%20What%20is%20CEF.htm) message.
720+
721+
`target` is a Getter that returns a string. If the returned string is empty, or cannot be parsed as CEF, an error will be returned.
722+
723+
`ParseCEF` is tolerant of an optional syslog header preceding the `CEF:` token; parsing begins at the first occurrence of `CEF:` in the input.
724+
725+
The returned map has the following top-level fields:
726+
727+
- `cef.version` — the CEF version (the integer following `CEF:`).
728+
- `cef.device_vendor`, `cef.device_product`, `cef.device_version`, `cef.device_event_class_id`, `cef.name`, `cef.severity` — the six CEF header fields.
729+
- `cef.extensions` — a map of the parsed key/value extension pairs.
730+
731+
Within the header fields, the escape sequences `\|` (pipe) and `\\` (backslash) are unescaped. Within extension values, the escape sequences `\\` (backslash), `\=` (equals), `\n` (newline), and `\r` (carriage return) are unescaped.
732+
733+
Extension parsing uses the position of the next `key=` token as the end of the current value, so values may contain spaces. All extension values are returned as strings.
734+
735+
Examples:
736+
737+
- `ParseCEF(body)`
738+
739+
- `ParseCEF("CEF:0|Security|threatmanager|1.0|100|worm successfully stopped|10|src=10.0.0.1 dst=2.1.2.2 spt=1232")`
740+
714741
### ParseCLF
715742

716743
`ParseCLF(target, Optional[format])`
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package logparsingfuncs // import "github.com/open-telemetry/opentelemetry-collector-contrib/processor/transformprocessor/internal/logparsingfuncs"
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"strings"
11+
12+
"go.opentelemetry.io/collector/pdata/pcommon"
13+
14+
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl"
15+
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl/contexts/ottllog"
16+
)
17+
18+
type parseCEFArguments struct {
19+
Target ottl.StringGetter[*ottllog.TransformContext]
20+
}
21+
22+
func NewParseCEFFactory() ottl.Factory[*ottllog.TransformContext] {
23+
return ottl.NewFactory("ParseCEF", &parseCEFArguments{}, createParseCEFFunction)
24+
}
25+
26+
func createParseCEFFunction(_ ottl.FunctionContext, oArgs ottl.Arguments) (ottl.ExprFunc[*ottllog.TransformContext], error) {
27+
args, ok := oArgs.(*parseCEFArguments)
28+
if !ok {
29+
return nil, errors.New("parseCEFFactory args must be of type *parseCEFArguments")
30+
}
31+
32+
return parseCEF(args.Target), nil
33+
}
34+
35+
func parseCEF(target ottl.StringGetter[*ottllog.TransformContext]) ottl.ExprFunc[*ottllog.TransformContext] {
36+
return func(ctx context.Context, tCtx *ottllog.TransformContext) (any, error) {
37+
source, err := target.Get(ctx, tCtx)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
if source == "" {
43+
return nil, errors.New("cannot parse empty CEF message")
44+
}
45+
46+
return parseCEFMessage(source)
47+
}
48+
}
49+
50+
type cefHeader struct {
51+
version string
52+
deviceVendor string
53+
deviceProduct string
54+
deviceVersion string
55+
deviceEventClassID string
56+
name string
57+
severity string
58+
}
59+
60+
// isCEFKeyChar reports whether c may appear in a CEF extension key. Keys
61+
// consist of ASCII alphanumerics and underscores.
62+
func isCEFKeyChar(c byte) bool {
63+
return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_'
64+
}
65+
66+
// cefKeyLen returns the length of the CEF extension key starting at extension[i]
67+
// followed by an `=`, or 0 if no valid key begins at that position. A key is a
68+
// run of key characters terminated by an `=`.
69+
func cefKeyLen(extension string, i int) int {
70+
j := i
71+
for j < len(extension) && isCEFKeyChar(extension[j]) {
72+
j++
73+
}
74+
if j > i && j < len(extension) && extension[j] == '=' {
75+
return j - i
76+
}
77+
return 0
78+
}
79+
80+
// countCEFExtensionKeys counts the number of `key=` tokens in the extension so
81+
// the destination map can be sized once up front.
82+
func countCEFExtensionKeys(extension string) int {
83+
count := 0
84+
for i := 0; i < len(extension); {
85+
if i == 0 || extension[i-1] == ' ' {
86+
if keyLen := cefKeyLen(extension, i); keyLen > 0 {
87+
count++
88+
i += keyLen + 1
89+
continue
90+
}
91+
}
92+
i++
93+
}
94+
return count
95+
}
96+
97+
func parseCEFMessage(message string) (pcommon.Map, error) {
98+
cefStart := strings.Index(message, "CEF:")
99+
if cefStart == -1 {
100+
return pcommon.Map{}, errors.New("invalid CEF message: 'CEF:' not found")
101+
}
102+
103+
cefMessage := message[cefStart:]
104+
105+
fields, err := splitCEFHeader(cefMessage)
106+
if err != nil {
107+
return pcommon.Map{}, err
108+
}
109+
110+
versionField := fields[0]
111+
version := strings.TrimPrefix(versionField, "CEF:")
112+
if version == "" {
113+
return pcommon.Map{}, errors.New("invalid CEF message: missing version")
114+
}
115+
116+
header := cefHeader{
117+
version: version,
118+
deviceVendor: unescapeCEFHeader(fields[1]),
119+
deviceProduct: unescapeCEFHeader(fields[2]),
120+
deviceVersion: unescapeCEFHeader(fields[3]),
121+
deviceEventClassID: unescapeCEFHeader(fields[4]),
122+
name: unescapeCEFHeader(fields[5]),
123+
severity: unescapeCEFHeader(fields[6]),
124+
}
125+
126+
var extension string
127+
if len(fields) == 8 {
128+
extension = fields[7]
129+
}
130+
131+
return buildCEFResult(header, extension), nil
132+
}
133+
134+
// splitCEFHeader splits a CEF message on unescaped pipes. The first seven
135+
// fields are the prefix (CEF:Version) and the six header fields. Everything
136+
// after the seventh pipe is the extension, returned as the eighth field if
137+
// present.
138+
func splitCEFHeader(message string) ([]string, error) {
139+
const headerFieldCount = 7
140+
141+
fields := make([]string, 0, headerFieldCount+1)
142+
var current strings.Builder
143+
144+
for i := 0; i < len(message); i++ {
145+
c := message[i]
146+
if c == '\\' && i+1 < len(message) {
147+
next := message[i+1]
148+
if next == '|' || next == '\\' {
149+
current.WriteByte(c)
150+
current.WriteByte(next)
151+
i++
152+
continue
153+
}
154+
}
155+
if c == '|' {
156+
fields = append(fields, current.String())
157+
current.Reset()
158+
if len(fields) == headerFieldCount {
159+
fields = append(fields, message[i+1:])
160+
return fields, nil
161+
}
162+
continue
163+
}
164+
current.WriteByte(c)
165+
}
166+
fields = append(fields, current.String())
167+
168+
if len(fields) < headerFieldCount {
169+
return nil, fmt.Errorf("invalid CEF header: expected at least %d pipe-delimited fields (CEF:Version, Device Vendor, Device Product, Device Version, Device Event Class ID, Name, Severity), got %d", headerFieldCount, len(fields))
170+
}
171+
return fields, nil
172+
}
173+
174+
// unescapeCEFHeader unescapes the two characters that may be escaped inside a
175+
// CEF header field: pipe and backslash.
176+
func unescapeCEFHeader(s string) string {
177+
if !strings.Contains(s, `\`) {
178+
return s
179+
}
180+
var b strings.Builder
181+
b.Grow(len(s))
182+
for i := 0; i < len(s); i++ {
183+
c := s[i]
184+
if c == '\\' && i+1 < len(s) {
185+
next := s[i+1]
186+
if next == '|' || next == '\\' {
187+
b.WriteByte(next)
188+
i++
189+
continue
190+
}
191+
}
192+
b.WriteByte(c)
193+
}
194+
return b.String()
195+
}
196+
197+
// unescapeCEFValue unescapes the four sequences that may appear inside a CEF
198+
// extension value: backslash, equals, newline, and carriage return.
199+
func unescapeCEFValue(s string) string {
200+
if !strings.Contains(s, `\`) {
201+
return s
202+
}
203+
var b strings.Builder
204+
b.Grow(len(s))
205+
for i := 0; i < len(s); i++ {
206+
c := s[i]
207+
if c == '\\' && i+1 < len(s) {
208+
switch s[i+1] {
209+
case '\\':
210+
b.WriteByte('\\')
211+
i++
212+
continue
213+
case '=':
214+
b.WriteByte('=')
215+
i++
216+
continue
217+
case 'n':
218+
b.WriteByte('\n')
219+
i++
220+
continue
221+
case 'r':
222+
b.WriteByte('\r')
223+
i++
224+
continue
225+
}
226+
}
227+
b.WriteByte(c)
228+
}
229+
return b.String()
230+
}
231+
232+
// parseCEFExtensions parses the extension portion of a CEF message directly
233+
// into dest as key/value pairs. Keys are runs of alphanumerics and underscores
234+
// preceded by the start of the string or a space. Values may contain spaces and
235+
// extend until the next `key=` token or the end of the string.
236+
func parseCEFExtensions(extension string, dest pcommon.Map) {
237+
dest.EnsureCapacity(countCEFExtensionKeys(extension))
238+
239+
haveKey := false
240+
var key string
241+
var valueStart int
242+
243+
for i := 0; i < len(extension); {
244+
if i == 0 || extension[i-1] == ' ' {
245+
if keyLen := cefKeyLen(extension, i); keyLen > 0 {
246+
if haveKey {
247+
value := strings.TrimRight(extension[valueStart:i], " ")
248+
dest.PutStr(key, unescapeCEFValue(value))
249+
}
250+
key = extension[i : i+keyLen]
251+
valueStart = i + keyLen + 1
252+
haveKey = true
253+
i = valueStart
254+
continue
255+
}
256+
}
257+
i++
258+
}
259+
260+
if haveKey {
261+
value := strings.TrimRight(extension[valueStart:], " ")
262+
dest.PutStr(key, unescapeCEFValue(value))
263+
}
264+
}
265+
266+
func buildCEFResult(header cefHeader, extension string) pcommon.Map {
267+
result := pcommon.NewMap()
268+
269+
result.PutStr("cef.version", header.version)
270+
result.PutStr("cef.device_vendor", header.deviceVendor)
271+
result.PutStr("cef.device_product", header.deviceProduct)
272+
result.PutStr("cef.device_version", header.deviceVersion)
273+
result.PutStr("cef.device_event_class_id", header.deviceEventClassID)
274+
result.PutStr("cef.name", header.name)
275+
result.PutStr("cef.severity", header.severity)
276+
277+
extensionsMap := result.PutEmptyMap("cef.extensions")
278+
if extension != "" {
279+
parseCEFExtensions(extension, extensionsMap)
280+
}
281+
282+
return result
283+
}

0 commit comments

Comments
 (0)