From 8f89d830f5646821fe49dadb54b3d74fc7d38d4a Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Fri, 10 Jul 2026 03:23:13 +0900 Subject: [PATCH 1/2] fix(docker): avoid panic on colliding nested crowdsec labels parseKeyToMap descended into nested label maps with an unchecked type assertion: m = m[parts[i]].(map[string]interface{}) When a container carries both a leaf label and a branch label under the same key (for example crowdsec.enable=true and crowdsec.enable.foo=bar), the intermediate key already holds a string, so the assertion panics with "interface conversion: interface {} is string, not map[string]interface {}". Container labels are set by whoever launches the container, so on a shared host this can crash the acquisition goroutine. Use the comma-ok form and replace a non-map (or absent) value with a fresh nested map. Added a regression test. Signed-off-by: Arpit Jain --- pkg/acquisition/modules/docker/docker_test.go | 27 +++++++++++++++---- pkg/acquisition/modules/docker/utils.go | 11 +++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pkg/acquisition/modules/docker/docker_test.go b/pkg/acquisition/modules/docker/docker_test.go index a353b0c1684..bd75aeeff03 100644 --- a/pkg/acquisition/modules/docker/docker_test.go +++ b/pkg/acquisition/modules/docker/docker_test.go @@ -92,7 +92,7 @@ func TestConfigureDSN(t *testing.T) { type mockDockerCli struct { client.Client - services []dockerTypesSwarm.Service + services []dockerTypesSwarm.Service } // Simplified Info method - just return basic info without complex types @@ -108,7 +108,7 @@ func (cli *mockDockerCli) ServiceList(_ context.Context, _ client.ServiceListOpt items := cli.services if items == nil { - defaultTestService := dockerTypesSwarm.Service{ + defaultTestService := dockerTypesSwarm.Service{ ID: "service123", Spec: dockerTypesSwarm.ServiceSpec{ Annotations: dockerTypesSwarm.Annotations{ @@ -522,8 +522,8 @@ func (*mockDockerCli) ContainerLogs(ctx context.Context, _ string, options clien func (*mockDockerCli) ContainerInspect(_ context.Context, _ string, _ client.ContainerInspectOptions) (client.ContainerInspectResult, error) { res := client.ContainerInspectResult{} res.Container = dockerContainer.InspectResponse{ - Config: &dockerContainer.Config{ Tty: false }, - State: &dockerContainer.State{ Running: true }, // Mock container is running + Config: &dockerContainer.Config{Tty: false}, + State: &dockerContainer.State{Running: true}, // Mock container is running } return res, nil @@ -542,6 +542,7 @@ func (*mockDockerCli) ServiceInspectWithRaw(_ context.Context, serviceID string, return service, []byte("{}"), nil } + // Since we are mocking the docker client, we return channels that will never be used func (*mockDockerCli) Events(_ context.Context, _ client.EventsListOptions) client.EventsResult { eventsChan := make(chan dockerTypesEvents.Message) @@ -549,7 +550,7 @@ func (*mockDockerCli) Events(_ context.Context, _ client.EventsListOptions) clie return client.EventsResult{ Messages: eventsChan, - Err: errChan, + Err: errChan, } } @@ -658,3 +659,19 @@ func TestParseLabels(t *testing.T) { }) } } + +func TestParseLabelsNestedCollisionDoesNotPanic(t *testing.T) { + // A leaf label and a branch label under the same key (set by whoever + // launches the container) must not panic the type assertion in + // parseKeyToMap. Map iteration order decides which wins, but neither + // ordering may crash, and unrelated labels must survive. + labels := map[string]string{ + "crowdsec.enable": "true", + "crowdsec.enable.foo": "bar", + "crowdsec.other": "keepme", + } + + var out map[string]any + require.NotPanics(t, func() { out = parseLabels(labels) }) + assert.Equal(t, "keepme", out["other"]) +} diff --git a/pkg/acquisition/modules/docker/utils.go b/pkg/acquisition/modules/docker/utils.go index 983dc94caee..b506ac3cb9f 100644 --- a/pkg/acquisition/modules/docker/utils.go +++ b/pkg/acquisition/modules/docker/utils.go @@ -28,10 +28,15 @@ func parseKeyToMap(m map[string]interface{}, key string, value string) { } for i := 1; i < len(parts)-1; i++ { - if _, ok := m[parts[i]]; !ok { - m[parts[i]] = make(map[string]interface{}) + next, ok := m[parts[i]].(map[string]interface{}) + if !ok { + // The key is absent, or a leaf value (e.g. a sibling label like + // crowdsec.enable=true) is already stored here. Replace it with a + // nested map instead of panicking on the type assertion. + next = make(map[string]interface{}) + m[parts[i]] = next } - m = m[parts[i]].(map[string]interface{}) + m = next } m[parts[len(parts)-1]] = value } From 0f5a33537558bd4b0c9c81a4f3054f9e09359ee0 Mon Sep 17 00:00:00 2001 From: arpitjain099 Date: Fri, 10 Jul 2026 20:17:52 +0900 Subject: [PATCH 2/2] fix(docker): iterate labels in sorted order for deterministic nesting Colliding nested labels (a leaf and a branch under the same key, e.g. crowdsec.enable and crowdsec.enable.foo) can only resolve one way, but Go map iteration order is random, so which value won was decided at random from run to run. Iterate over sorted keys so the shorter, less specific key is always applied first and then overwritten by the more specific one. Strengthen the test to assert the branch wins deterministically and unrelated labels survive. Signed-off-by: arpitjain099 --- pkg/acquisition/modules/docker/docker_test.go | 19 ++++++++++++++----- pkg/acquisition/modules/docker/utils.go | 18 ++++++++++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/pkg/acquisition/modules/docker/docker_test.go b/pkg/acquisition/modules/docker/docker_test.go index bd75aeeff03..33940f80815 100644 --- a/pkg/acquisition/modules/docker/docker_test.go +++ b/pkg/acquisition/modules/docker/docker_test.go @@ -663,15 +663,24 @@ func TestParseLabels(t *testing.T) { func TestParseLabelsNestedCollisionDoesNotPanic(t *testing.T) { // A leaf label and a branch label under the same key (set by whoever // launches the container) must not panic the type assertion in - // parseKeyToMap. Map iteration order decides which wins, but neither - // ordering may crash, and unrelated labels must survive. + // parseKeyToMap. Only one of the two can win, but the result must be the + // same every run (not decided by random map order) and unrelated labels + // must survive. labels := map[string]string{ "crowdsec.enable": "true", "crowdsec.enable.foo": "bar", "crowdsec.other": "keepme", } - var out map[string]any - require.NotPanics(t, func() { out = parseLabels(labels) }) - assert.Equal(t, "keepme", out["other"]) + // Sorting inside parseLabels applies the shorter "crowdsec.enable" first, + // then the more specific "crowdsec.enable.foo" overwrites it, so the branch + // wins deterministically. Run it repeatedly to catch any order dependence. + var first map[string]any + require.NotPanics(t, func() { first = parseLabels(labels) }) + assert.Equal(t, "keepme", first["other"]) + assert.Equal(t, map[string]any{"foo": "bar"}, first["enable"]) + + for i := 0; i < 50; i++ { + assert.Equal(t, first, parseLabels(labels)) + } } diff --git a/pkg/acquisition/modules/docker/utils.go b/pkg/acquisition/modules/docker/utils.go index b506ac3cb9f..1e6a75a6754 100644 --- a/pkg/acquisition/modules/docker/utils.go +++ b/pkg/acquisition/modules/docker/utils.go @@ -7,9 +7,23 @@ import ( func parseLabels(labels map[string]string) map[string]interface{} { result := make(map[string]interface{}) - for key, value := range labels { - parseKeyToMap(result, key, value) + + // Iterate in sorted key order so the result is deterministic. Go map + // iteration order is random, and when a leaf and a branch collide under the + // same key (e.g. crowdsec.enable and crowdsec.enable.foo) only one can win; + // sorting means the shorter, less specific key is always applied first and + // then overwritten by the more specific one, instead of the winner being + // decided at random from run to run. + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + slices.Sort(keys) + + for _, key := range keys { + parseKeyToMap(result, key, labels[key]) } + return result }