Skip to content

Commit ee07796

Browse files
authored
Merge pull request #48 from Icinga/fix-syslog-identifier-for-journald
logging: Sanitize Journald Field Key Names
2 parents 23a1e76 + 3f3ec3a commit ee07796

File tree

2 files changed

+91
-8
lines changed

2 files changed

+91
-8
lines changed

logging/journald_core.go

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"github.com/pkg/errors"
66
"github.com/ssgreg/journald"
77
"go.uber.org/zap/zapcore"
8-
"strings"
98
)
109

1110
// priorities maps zapcore.Level to journal.Priority.
@@ -25,15 +24,13 @@ func NewJournaldCore(identifier string, enab zapcore.LevelEnabler) zapcore.Core
2524
return &journaldCore{
2625
LevelEnabler: enab,
2726
identifier: identifier,
28-
identifierU: strings.ToUpper(identifier),
2927
}
3028
}
3129

3230
type journaldCore struct {
3331
zapcore.LevelEnabler
34-
context []zapcore.Field
35-
identifier string
36-
identifierU string
32+
context []zapcore.Field
33+
identifier string
3734
}
3835

3936
func (c *journaldCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
@@ -62,6 +59,7 @@ func (c *journaldCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
6259
}
6360

6461
enc := zapcore.NewMapObjectEncoder()
62+
// Ensure that all field keys are valid journald field keys. If in doubt, use encodeJournaldFieldKey.
6563
c.addFields(enc, fields)
6664
c.addFields(enc, c.context)
6765
enc.Fields["SYSLOG_IDENTIFIER"] = c.identifier
@@ -74,11 +72,55 @@ func (c *journaldCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
7472
return journald.Send(message, pri, enc.Fields)
7573
}
7674

75+
// addFields adds all given fields to enc with an altered key, prefixed with the journaldCore.identifier and sanitized
76+
// via encodeJournaldFieldKey.
7777
func (c *journaldCore) addFields(enc zapcore.ObjectEncoder, fields []zapcore.Field) {
7878
for _, field := range fields {
79-
field.Key = c.identifierU +
80-
"_" +
81-
strcase.ScreamingSnake(field.Key)
79+
field.Key = encodeJournaldFieldKey(c.identifier + "_" + field.Key)
8280
field.AddTo(enc)
8381
}
8482
}
83+
84+
// encodeJournaldFieldKey alters a string to be used as a journald field key.
85+
//
86+
// When journald receives a field with an invalid key, it silently discards this field. This makes syntactically correct
87+
// keys a necessity. Unfortunately, there was no specific documentation about the field key syntax available. This
88+
// function follows the logic enforced in systemd's journal_field_valid function[0].
89+
//
90+
// This boils down to:
91+
// - Key length MUST be within (0, 64] characters.
92+
// - Key MUST start with [A-Z].
93+
// - Key characters MUST be [A-Z0-9_].
94+
//
95+
// [0]: https://github.com/systemd/systemd/blob/11d5e2b5fbf9f6bfa5763fd45b56829ad4f0777f/src/libsystemd/sd-journal/journal-file.c#L1703
96+
func encodeJournaldFieldKey(key string) string {
97+
if len(key) == 0 {
98+
// While this is definitely an error, panicking would be too destructive and silently dropping fields is against
99+
// the very idea of ensuring key conformity.
100+
return "EMPTY_KEY"
101+
}
102+
103+
isAsciiUpper := func(r rune) bool { return 'A' <= r && r <= 'Z' }
104+
isAsciiDigit := func(r rune) bool { return '0' <= r && r <= '9' }
105+
106+
keyParts := []rune(strcase.ScreamingSnake(key))
107+
for i, r := range keyParts {
108+
if isAsciiUpper(r) || isAsciiDigit(r) || r == '_' {
109+
continue
110+
}
111+
keyParts[i] = '_'
112+
}
113+
key = string(keyParts)
114+
115+
if !isAsciiUpper(rune(key[0])) {
116+
// Escape invalid leading characters with a generic "ESC_" prefix. This was seen as a safer choice instead of
117+
// iterating over the key and removing parts.
118+
key = "ESC_" + key
119+
}
120+
121+
if len(key) > 64 {
122+
key = key[:64]
123+
}
124+
125+
return key
126+
}

logging/journald_core_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package logging
2+
3+
import (
4+
"github.com/stretchr/testify/require"
5+
"regexp"
6+
"testing"
7+
)
8+
9+
func Test_journaldFieldEncode(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
output string
14+
}{
15+
{"empty", "", "EMPTY_KEY"},
16+
{"lowercase", "foo", "FOO"},
17+
{"uppercase", "FOO", "FOO"},
18+
{"dash", "foo-bar", "FOO_BAR"},
19+
{"non ascii", "snow_☃", "SNOW__"},
20+
{"lowercase non ascii alpha", "föö", "F__"},
21+
{"uppercase non ascii alpha", "FÖÖ", "F__"},
22+
{"leading number", "23", "ESC_23"},
23+
{"leading underscore", "_foo", "ESC__FOO"},
24+
{"leading invalid", " foo", "ESC__FOO"},
25+
{"max length", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1234", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1234"},
26+
{"too long", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA12345", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1234"},
27+
{"too long leading number", "1234567890123456789012345678901234567890123456789012345678901234", "ESC_123456789012345678901234567890123456789012345678901234567890"},
28+
{"concrete example", "icinga-notifications" + "_" + "error", "ICINGA_NOTIFICATIONS_ERROR"},
29+
{"example syslog_identifier", "SYSLOG_IDENTIFIER", "SYSLOG_IDENTIFIER"},
30+
}
31+
32+
check := regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`)
33+
34+
for _, test := range tests {
35+
t.Run(test.name, func(t *testing.T) {
36+
out := encodeJournaldFieldKey(test.input)
37+
require.Equal(t, test.output, out)
38+
require.True(t, check.MatchString(out), "check regular expression")
39+
})
40+
}
41+
}

0 commit comments

Comments
 (0)