Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions pkg/logs/internal/parsers/dockerfile/docker_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,27 @@ import (

// New returns a new parser which will parse raw JSON lines as found in docker log files.
//
// For example:
// The parser handles Docker's JSON log format where each line represents output
// from a container. A trailing newline (\n) indicates a complete line and is
// stripped from the content. The absence of a trailing newline indicates a
// partial line (e.g., a prompt waiting for input).
//
// `{"log":"a message","stream":"stderr","time":"2019-06-06T16:35:55.930852911Z"}`
// Examples:
//
// returns:
//
// parsers.Message {
// Content: []byte("a message"),
// `{"log":"a message\n","stream":"stderr","time":"2019-06-06T16:35:55.930852911Z"}`
// returns:
// Content: []byte("a message"), // newline stripped
// Status: "error",
// Timestamp: "2019-06-06T16:35:55.930852911Z",
// IsPartial: false,
// }
// IsPartial: false, // complete line
//
// `{"log":"a prompt: ","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`
// returns:
// Content: []byte("a prompt: "), // no newline to strip
// Status: "info",
// IsPartial: true, // partial line
//
// Note: Only the final newline is stripped. Multiple newlines (e.g., "\n\n")
// represent content with empty lines, so "\n\n" becomes "\n" after parsing.
func New() parsers.Parser {
return &dockerFileFormat{}
}
Expand All @@ -50,6 +59,12 @@ func (p *dockerFileFormat) Parse(msg *message.Message) (*message.Message, error)
return msg, fmt.Errorf("cannot parse docker message, invalid JSON: %v", err)
}

// Check if log is nil (e.g., when input is the JSON literal null)
if log == nil {
msg.Status = message.StatusInfo
return msg, fmt.Errorf("cannot parse docker message, invalid format: got null")
}

var status string
switch log.Stream {
case "stderr":
Expand Down
117 changes: 117 additions & 0 deletions pkg/logs/internal/parsers/dockerfile/docker_file_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

package dockerfile

import (
"encoding/json"
"testing"

"github.com/DataDog/datadog-agent/pkg/logs/message"
)

func FuzzParseDockerFile(f *testing.F) {
// Seed corpus
f.Add([]byte(`{"log":"hello world\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
f.Add([]byte(`{"log":"error message","stream":"stderr","time":"2019-06-06T16:35:55.930852911Z"}`))
f.Add([]byte(`{"log":"","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
f.Add([]byte(`{"log":"\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
f.Add([]byte(`{"log":"\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))

// Invalid JSON that triggered the bug
f.Add([]byte(`null`))
f.Add([]byte(`{}`))
f.Add([]byte(`[]`))
f.Add([]byte(`"string"`))
f.Add([]byte(`123`))
f.Add([]byte(`true`))
f.Add([]byte(`false`))

// Malformed JSON
f.Add([]byte(`{`))
f.Add([]byte(`{"log":"unclosed`))
f.Add([]byte(``))

// Type confusion
f.Add([]byte(`{"log":123,"stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
f.Add([]byte(`{"log":null,"stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))
f.Add([]byte(`{"log":["array"],"stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`))

parser := New()

f.Fuzz(func(t *testing.T, data []byte) {
msg := message.NewMessage(data, nil, "", 0)
originalContent := msg.GetContent()

// Parser should not panic
result, err := parser.Parse(msg)

// Critical invariants
if result != msg {
t.Fatalf("Parser returned different message object")
}

if err != nil {
// On error: status should be Info and content should be preserved
if result.Status != message.StatusInfo {
t.Errorf("Failed parse should have Info status, got %s", result.Status)
}
if string(result.GetContent()) != string(originalContent) {
t.Errorf("Failed parse should preserve original content")
}
} else {
// On success: status should be Info, Error, or empty
switch result.Status {
case message.StatusInfo, message.StatusError, "":
// valid
default:
t.Errorf("Unexpected status: %s", result.Status)
}

// Parse JSON to verify behavior matches what we parsed
var parsed logLine
if json.Unmarshal(originalContent, &parsed) == nil {
// Stream mapping
if parsed.Stream == "stderr" && result.Status != message.StatusError {
t.Errorf("stderr stream should map to StatusError, got %s", result.Status)
} else if parsed.Stream == "stdout" && result.Status != message.StatusInfo {
t.Errorf("stdout stream should map to StatusInfo, got %s", result.Status)
}

// Timestamp should match
if result.ParsingExtra.Timestamp != parsed.Time {
t.Errorf("Timestamp mismatch: got %q, expected %q", result.ParsingExtra.Timestamp, parsed.Time)
}

// Newline and IsPartial handling
if len(parsed.Log) > 0 && parsed.Log[len(parsed.Log)-1] == '\n' {
// Should NOT be partial
if result.ParsingExtra.IsPartial {
t.Errorf("Log ending with newline should not be partial")
}
// Content should be stripped by exactly one newline
expected := parsed.Log[:len(parsed.Log)-1]
if string(result.GetContent()) != expected {
t.Errorf("Content mismatch: got %q, expected %q", string(result.GetContent()), expected)
}
} else if len(parsed.Log) > 0 {
// Should be partial
if !result.ParsingExtra.IsPartial {
t.Errorf("Log not ending with newline should be partial")
}
// Content should match exactly
if string(result.GetContent()) != parsed.Log {
t.Errorf("Content mismatch: got %q, expected %q", string(result.GetContent()), parsed.Log)
}
} else {
// Empty log - should not be partial
if result.ParsingExtra.IsPartial {
t.Errorf("Empty log should not be partial")
}
}
}
}
})
}
120 changes: 120 additions & 0 deletions pkg/logs/internal/parsers/dockerfile/docker_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,123 @@ func TestDockerFileFormat(t *testing.T) {
assert.Equal(t, message.StatusInfo, msg.Status)
assert.Equal(t, "2019-06-06T16:35:55.930852915Z", msg.ParsingExtra.Timestamp)
}

func TestDockerFileFormatNullJSON(t *testing.T) {
parser := New()

// This test reproduces the bug found by fuzzing - JSON unmarshal succeeds
// but returns nil, causing a panic when accessing log.Stream
// Examples: the JSON literal null, arrays, primitives, etc.
testCases := []struct {
name string
input []byte
}{
{
name: "JSON literal null",
input: []byte(`null`),
},
{
name: "JSON array",
input: []byte(`[]`),
},
{
name: "JSON string",
input: []byte(`"string"`),
},
{
name: "JSON number",
input: []byte(`123`),
},
{
name: "JSON boolean true",
input: []byte(`true`),
},
{
name: "JSON boolean false",
input: []byte(`false`),
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
logMessage := message.NewMessage(tc.input, nil, "", 0)

// This should not panic
assert.NotPanics(t, func() {
msg, err := parser.Parse(logMessage)
// Should return an error for these cases
assert.NotNil(t, err)
assert.Equal(t, message.StatusInfo, msg.Status)
assert.Equal(t, tc.input, msg.GetContent())
})
})
}
}

func TestDockerFileFormatMultipleNewlines(t *testing.T) {
parser := New()

// This test documents the parser's behavior with newlines. The parser
// strips exactly ONE trailing newline (the line terminator). Multiple
// newlines represent actual content (empty lines).
testCases := []struct {
name string
input []byte
expectedContent []byte
expectedPartial bool
}{
{
name: "empty log without newline",
input: []byte(`{"log":"","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte(""),
expectedPartial: false, // Empty content is not partial
},
{
name: "single newline",
input: []byte(`{"log":"\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte(""),
expectedPartial: false,
},
{
name: "double newline",
input: []byte(`{"log":"\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte("\n"),
expectedPartial: false,
},
{
name: "triple newline",
input: []byte(`{"log":"\n\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte("\n\n"),
expectedPartial: false,
},
{
name: "text with trailing newline",
input: []byte(`{"log":"hello\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte("hello"),
expectedPartial: false,
},
{
name: "text with multiple trailing newlines",
input: []byte(`{"log":"hello\n\n","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte("hello\n"),
expectedPartial: false,
},
{
name: "text without trailing newline",
input: []byte(`{"log":"hello","stream":"stdout","time":"2019-06-06T16:35:55.930852911Z"}`),
expectedContent: []byte("hello"),
expectedPartial: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
logMessage := message.NewMessage(tc.input, nil, "", 0)
msg, err := parser.Parse(logMessage)
assert.Nil(t, err)
assert.Equal(t, tc.expectedContent, msg.GetContent())
assert.Equal(t, tc.expectedPartial, msg.ParsingExtra.IsPartial)
assert.Equal(t, message.StatusInfo, msg.Status)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Each section from every release note are combined when the
# CHANGELOG.rst is rendered. So the text needs to be worded so that
# it does not depend on any information only available in another
# section. This may mean repeating some details, but each section
# must be readable independently of the other.
#
# Each section note must be formatted as reStructuredText.
---
fixes:
- |
Fix a panic in Docker file log parsing when received messages are null equivalent.
Loading