Skip to content

Commit dfa50d6

Browse files
bltmichaelcretzman
andauthored
Introduce fuzz tests for docker file logs parsing (#38733)
Signed-off-by: Brian L. Troutwine <brian.troutwine@datadoghq.com> Co-authored-by: Michael Cretzman <58786311+michaelcretzman@users.noreply.github.com>
1 parent 138febe commit dfa50d6

4 files changed

Lines changed: 272 additions & 9 deletions

File tree

pkg/logs/internal/parsers/dockerfile/docker_file.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,27 @@ import (
1717

1818
// New returns a new parser which will parse raw JSON lines as found in docker log files.
1919
//
20-
// For example:
20+
// The parser handles Docker's JSON log format where each line represents output
21+
// from a container. A trailing newline (\n) indicates a complete line and is
22+
// stripped from the content. The absence of a trailing newline indicates a
23+
// partial line (e.g., a prompt waiting for input).
2124
//
22-
// `{"log":"a message","stream":"stderr","time":"2019-06-06T16:35:55.930852911Z"}`
25+
// Examples:
2326
//
24-
// returns:
25-
//
26-
// parsers.Message {
27-
// Content: []byte("a message"),
27+
// `{"log":"a message\n","stream":"stderr","time":"2019-06-06T16:35:55.930852911Z"}`
28+
// returns:
29+
// Content: []byte("a message"), // newline stripped
2830
// Status: "error",
29-
// Timestamp: "2019-06-06T16:35:55.930852911Z",
30-
// IsPartial: false,
31-
// }
31+
// IsPartial: false, // complete line
32+
//
33+
// `{"log":"a prompt: ","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`
34+
// returns:
35+
// Content: []byte("a prompt: "), // no newline to strip
36+
// Status: "info",
37+
// IsPartial: true, // partial line
38+
//
39+
// Note: Only the final newline is stripped. Multiple newlines (e.g., "\n\n")
40+
// represent content with empty lines, so "\n\n" becomes "\n" after parsing.
3241
func New() parsers.Parser {
3342
return &dockerFileFormat{}
3443
}
@@ -50,6 +59,12 @@ func (p *dockerFileFormat) Parse(msg *message.Message) (*message.Message, error)
5059
return msg, fmt.Errorf("cannot parse docker message, invalid JSON: %v", err)
5160
}
5261

62+
// Check if log is nil (e.g., when input is the JSON literal null)
63+
if log == nil {
64+
msg.Status = message.StatusInfo
65+
return msg, fmt.Errorf("cannot parse docker message, invalid format: got null")
66+
}
67+
5368
var status string
5469
switch log.Stream {
5570
case "stderr":
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016-present Datadog, Inc.
5+
6+
package dockerfile
7+
8+
import (
9+
"encoding/json"
10+
"testing"
11+
12+
"github.com/DataDog/datadog-agent/pkg/logs/message"
13+
)
14+
15+
func FuzzParseDockerFile(f *testing.F) {
16+
// Seed corpus
17+
f.Add([]byte(`{"log":"hello world\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
18+
f.Add([]byte(`{"log":"error message","stream":"stderr","time":"2019-06-06T16:35:55.930852911Z"}`))
19+
f.Add([]byte(`{"log":"","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
20+
f.Add([]byte(`{"log":"\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
21+
f.Add([]byte(`{"log":"\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
22+
23+
// Invalid JSON that triggered the bug
24+
f.Add([]byte(`null`))
25+
f.Add([]byte(`{}`))
26+
f.Add([]byte(`[]`))
27+
f.Add([]byte(`"string"`))
28+
f.Add([]byte(`123`))
29+
f.Add([]byte(`true`))
30+
f.Add([]byte(`false`))
31+
32+
// Malformed JSON
33+
f.Add([]byte(`{`))
34+
f.Add([]byte(`{"log":"unclosed`))
35+
f.Add([]byte(``))
36+
37+
// Type confusion
38+
f.Add([]byte(`{"log":123,"stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
39+
f.Add([]byte(`{"log":null,"stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
40+
f.Add([]byte(`{"log":["array"],"stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
41+
42+
parser := New()
43+
44+
f.Fuzz(func(t *testing.T, data []byte) {
45+
msg := message.NewMessage(data, nil, "", 0)
46+
originalContent := msg.GetContent()
47+
48+
// Parser should not panic
49+
result, err := parser.Parse(msg)
50+
51+
// Critical invariants
52+
if result != msg {
53+
t.Fatalf("Parser returned different message object")
54+
}
55+
56+
if err != nil {
57+
// On error: status should be Info and content should be preserved
58+
if result.Status != message.StatusInfo {
59+
t.Errorf("Failed parse should have Info status, got %s", result.Status)
60+
}
61+
if string(result.GetContent()) != string(originalContent) {
62+
t.Errorf("Failed parse should preserve original content")
63+
}
64+
} else {
65+
// On success: status should be Info, Error, or empty
66+
switch result.Status {
67+
case message.StatusInfo, message.StatusError, "":
68+
// valid
69+
default:
70+
t.Errorf("Unexpected status: %s", result.Status)
71+
}
72+
73+
// Parse JSON to verify behavior matches what we parsed
74+
var parsed logLine
75+
if json.Unmarshal(originalContent, &parsed) == nil {
76+
// Stream mapping
77+
if parsed.Stream == "stderr" && result.Status != message.StatusError {
78+
t.Errorf("stderr stream should map to StatusError, got %s", result.Status)
79+
} else if parsed.Stream == "stdout" && result.Status != message.StatusInfo {
80+
t.Errorf("stdout stream should map to StatusInfo, got %s", result.Status)
81+
}
82+
83+
// Timestamp should match
84+
if result.ParsingExtra.Timestamp != parsed.Time {
85+
t.Errorf("Timestamp mismatch: got %q, expected %q", result.ParsingExtra.Timestamp, parsed.Time)
86+
}
87+
88+
// Newline and IsPartial handling
89+
if len(parsed.Log) > 0 && parsed.Log[len(parsed.Log)-1] == '\n' {
90+
// Should NOT be partial
91+
if result.ParsingExtra.IsPartial {
92+
t.Errorf("Log ending with newline should not be partial")
93+
}
94+
// Content should be stripped by exactly one newline
95+
expected := parsed.Log[:len(parsed.Log)-1]
96+
if string(result.GetContent()) != expected {
97+
t.Errorf("Content mismatch: got %q, expected %q", string(result.GetContent()), expected)
98+
}
99+
} else if len(parsed.Log) > 0 {
100+
// Should be partial
101+
if !result.ParsingExtra.IsPartial {
102+
t.Errorf("Log not ending with newline should be partial")
103+
}
104+
// Content should match exactly
105+
if string(result.GetContent()) != parsed.Log {
106+
t.Errorf("Content mismatch: got %q, expected %q", string(result.GetContent()), parsed.Log)
107+
}
108+
} else {
109+
// Empty log - should not be partial
110+
if result.ParsingExtra.IsPartial {
111+
t.Errorf("Empty log should not be partial")
112+
}
113+
}
114+
}
115+
}
116+
})
117+
}

pkg/logs/internal/parsers/dockerfile/docker_file_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,123 @@ func TestDockerFileFormat(t *testing.T) {
6262
assert.Equal(t, message.StatusInfo, msg.Status)
6363
assert.Equal(t, "2019-06-06T16:35:55.930852915Z", msg.ParsingExtra.Timestamp)
6464
}
65+
66+
func TestDockerFileFormatNullJSON(t *testing.T) {
67+
parser := New()
68+
69+
// This test reproduces the bug found by fuzzing - JSON unmarshal succeeds
70+
// but returns nil, causing a panic when accessing log.Stream
71+
// Examples: the JSON literal null, arrays, primitives, etc.
72+
testCases := []struct {
73+
name string
74+
input []byte
75+
}{
76+
{
77+
name: "JSON literal null",
78+
input: []byte(`null`),
79+
},
80+
{
81+
name: "JSON array",
82+
input: []byte(`[]`),
83+
},
84+
{
85+
name: "JSON string",
86+
input: []byte(`"string"`),
87+
},
88+
{
89+
name: "JSON number",
90+
input: []byte(`123`),
91+
},
92+
{
93+
name: "JSON boolean true",
94+
input: []byte(`true`),
95+
},
96+
{
97+
name: "JSON boolean false",
98+
input: []byte(`false`),
99+
},
100+
}
101+
102+
for _, tc := range testCases {
103+
t.Run(tc.name, func(t *testing.T) {
104+
logMessage := message.NewMessage(tc.input, nil, "", 0)
105+
106+
// This should not panic
107+
assert.NotPanics(t, func() {
108+
msg, err := parser.Parse(logMessage)
109+
// Should return an error for these cases
110+
assert.NotNil(t, err)
111+
assert.Equal(t, message.StatusInfo, msg.Status)
112+
assert.Equal(t, tc.input, msg.GetContent())
113+
})
114+
})
115+
}
116+
}
117+
118+
func TestDockerFileFormatMultipleNewlines(t *testing.T) {
119+
parser := New()
120+
121+
// This test documents the parser's behavior with newlines. The parser
122+
// strips exactly ONE trailing newline (the line terminator). Multiple
123+
// newlines represent actual content (empty lines).
124+
testCases := []struct {
125+
name string
126+
input []byte
127+
expectedContent []byte
128+
expectedPartial bool
129+
}{
130+
{
131+
name: "empty log without newline",
132+
input: []byte(`{"log":"","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
133+
expectedContent: []byte(""),
134+
expectedPartial: false, // Empty content is not partial
135+
},
136+
{
137+
name: "single newline",
138+
input: []byte(`{"log":"\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
139+
expectedContent: []byte(""),
140+
expectedPartial: false,
141+
},
142+
{
143+
name: "double newline",
144+
input: []byte(`{"log":"\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
145+
expectedContent: []byte("\n"),
146+
expectedPartial: false,
147+
},
148+
{
149+
name: "triple newline",
150+
input: []byte(`{"log":"\n\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
151+
expectedContent: []byte("\n\n"),
152+
expectedPartial: false,
153+
},
154+
{
155+
name: "text with trailing newline",
156+
input: []byte(`{"log":"hello\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
157+
expectedContent: []byte("hello"),
158+
expectedPartial: false,
159+
},
160+
{
161+
name: "text with multiple trailing newlines",
162+
input: []byte(`{"log":"hello\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
163+
expectedContent: []byte("hello\n"),
164+
expectedPartial: false,
165+
},
166+
{
167+
name: "text without trailing newline",
168+
input: []byte(`{"log":"hello","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
169+
expectedContent: []byte("hello"),
170+
expectedPartial: true,
171+
},
172+
}
173+
174+
for _, tc := range testCases {
175+
t.Run(tc.name, func(t *testing.T) {
176+
logMessage := message.NewMessage(tc.input, nil, "", 0)
177+
msg, err := parser.Parse(logMessage)
178+
assert.Nil(t, err)
179+
assert.Equal(t, tc.expectedContent, msg.GetContent())
180+
assert.Equal(t, tc.expectedPartial, msg.ParsingExtra.IsPartial)
181+
assert.Equal(t, message.StatusInfo, msg.Status)
182+
})
183+
}
184+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Each section from every release note are combined when the
2+
# CHANGELOG.rst is rendered. So the text needs to be worded so that
3+
# it does not depend on any information only available in another
4+
# section. This may mean repeating some details, but each section
5+
# must be readable independently of the other.
6+
#
7+
# Each section note must be formatted as reStructuredText.
8+
---
9+
fixes:
10+
- |
11+
Fix a panic in Docker file log parsing when received messages are null equivalent.

0 commit comments

Comments
 (0)