From b234bd035f848e0393d0ba2e3843d3e17c0c2c71 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 17 Mar 2026 23:48:03 +0100 Subject: [PATCH 1/3] feat(attestation): preserve chainloop.* annotations in NormalizedMaterial Previously, normalizeMaterial() stripped all chainloop.* prefixed annotations from NormalizedMaterial.Annotations. This made internal system annotations (e.g. chainloop.material.size, chainloop.material.cas, chainloop.material.tool.name) unavailable to downstream consumers such as the attestation service, graph view, and policy evaluation. Remove the continue that skipped chainloop.* keys and add a structValueToString helper to correctly convert all structpb value types (string, bool, number) since some annotations like chainloop.material.cas are stored as BoolValue, for which GetStringValue() returns "". Fixes: https://github.com/chainloop-dev/chainloop/issues/2872 Signed-off-by: Miguel Martinez Trivino --- .../renderer/chainloop/chainloop.go | 6 +- .../renderer/chainloop/chainloop_test.go | 38 ++++++++++--- pkg/attestation/renderer/chainloop/v02.go | 29 +++++++--- .../renderer/chainloop/v02_test.go | 57 +++++++++++++++---- 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/pkg/attestation/renderer/chainloop/chainloop.go b/pkg/attestation/renderer/chainloop/chainloop.go index 634461309..53c1490d9 100644 --- a/pkg/attestation/renderer/chainloop/chainloop.go +++ b/pkg/attestation/renderer/chainloop/chainloop.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -75,7 +75,9 @@ type NormalizedMaterial struct { UploadedToCAS bool // Whether the Material was embedded inline in the attestation EmbeddedInline bool - // Custom annotations + // Annotations contains all material annotations, both user-defined and + // internal chainloop.* system annotations (e.g. chainloop.material.type, + // chainloop.material.cas, chainloop.material.size, etc.) Annotations map[string]string // Referenced source component, for SBOMs, SARIF files, etc ReferencedSourceComponent *ReferencedSourceComponent diff --git a/pkg/attestation/renderer/chainloop/chainloop_test.go b/pkg/attestation/renderer/chainloop/chainloop_test.go index 2d57e6ec1..b05cbb138 100644 --- a/pkg/attestation/renderer/chainloop/chainloop_test.go +++ b/pkg/attestation/renderer/chainloop/chainloop_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -44,20 +44,33 @@ func TestExtractPredicate(t *testing.T) { Filename: "main.go", Hash: &crv1.Hash{Algorithm: "sha256", Hex: "8fce0203a4efaac3b08ee3ad769233039faa762a3da0777c45b315f398f0c150"}, UploadedToCAS: true, - Annotations: map[string]string{"annotation": "baz"}, + Annotations: map[string]string{ + "annotation": "baz", + "chainloop.material.cas": "true", + "chainloop.material.name": "binary", + "chainloop.material.type": "ARTIFACT", + }, }, { Name: "image", Type: "CONTAINER_IMAGE", - Value: "index.docker.io/bitnami/nginx", - Hash: &crv1.Hash{Algorithm: "sha256", Hex: "747ef335ea27a2faf08aa292a5bc5491aff50c6a94ee4ebcbbcd43cdeccccaf1"}, - Annotations: map[string]string{"another_annotation": "foo"}, + Value: "index.docker.io/bitnami/nginx", + Hash: &crv1.Hash{Algorithm: "sha256", Hex: "747ef335ea27a2faf08aa292a5bc5491aff50c6a94ee4ebcbbcd43cdeccccaf1"}, + Annotations: map[string]string{ + "another_annotation": "foo", + "chainloop.material.name": "image", + "chainloop.material.type": "CONTAINER_IMAGE", + }, }, { Name: "sbom", Type: "SBOM_CYCLONEDX_JSON", Filename: "sbom.cyclonedx.json", Hash: &crv1.Hash{Algorithm: "sha256", Hex: "16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c"}, UploadedToCAS: true, - Annotations: make(map[string]string), + Annotations: map[string]string{ + "chainloop.material.cas": "true", + "chainloop.material.name": "sbom", + "chainloop.material.type": "SBOM_CYCLONEDX_JSON", + }, }, { Name: "sbom", Type: "SBOM_CYCLONEDX_JSON", @@ -65,12 +78,19 @@ func TestExtractPredicate(t *testing.T) { Hash: &crv1.Hash{Algorithm: "sha256", Hex: "16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c"}, EmbeddedInline: true, Value: "hello inline!", - Annotations: make(map[string]string), + Annotations: map[string]string{ + "chainloop.material.cas.inline": "true", + "chainloop.material.name": "sbom", + "chainloop.material.type": "SBOM_CYCLONEDX_JSON", + }, }, { Name: "stringvar", Type: "STRING", - Value: "helloworld", - Annotations: make(map[string]string), + Value: "helloworld", + Annotations: map[string]string{ + "chainloop.material.name": "stringvar", + "chainloop.material.type": "STRING", + }, }, }, }, diff --git a/pkg/attestation/renderer/chainloop/v02.go b/pkg/attestation/renderer/chainloop/v02.go index 63e1ac647..20b48680b 100644 --- a/pkg/attestation/renderer/chainloop/v02.go +++ b/pkg/attestation/renderer/chainloop/v02.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "sort" + "strconv" "strings" "time" @@ -429,17 +430,11 @@ func (p *ProvenancePredicateV02) GetPolicyEvaluationStatus() *PolicyEvaluationSt func normalizeMaterial(material *intoto.ResourceDescriptor) (*NormalizedMaterial, error) { m := &NormalizedMaterial{} - // Set custom annotations + // Set annotations (both custom and chainloop.* prefixed) m.Annotations = make(map[string]string) mAnnotationsMap := material.Annotations.GetFields() for k, v := range mAnnotationsMap { - // if the annotation key doesn't start with chainloop. - // we set it as a custom annotation - if strings.HasPrefix(k, v1.AnnotationPrefix) { - continue - } - - m.Annotations[k] = v.GetStringValue() + m.Annotations[k] = structValueToString(v) } mType, ok := mAnnotationsMap[v1.AnnotationMaterialType] @@ -530,3 +525,19 @@ func normalizeMaterial(material *intoto.ResourceDescriptor) (*NormalizedMaterial return m, nil } + +// structValueToString converts a structpb.Value to its string representation. +// Some annotations (e.g. chainloop.material.cas) are stored as BoolValue, +// for which GetStringValue() returns "". +func structValueToString(v *structpb.Value) string { + switch v.GetKind().(type) { + case *structpb.Value_StringValue: + return v.GetStringValue() + case *structpb.Value_BoolValue: + return strconv.FormatBool(v.GetBoolValue()) + case *structpb.Value_NumberValue: + return fmt.Sprintf("%g", v.GetNumberValue()) + default: + return "" + } +} diff --git a/pkg/attestation/renderer/chainloop/v02_test.go b/pkg/attestation/renderer/chainloop/v02_test.go index 58c497a11..8caaf2b72 100644 --- a/pkg/attestation/renderer/chainloop/v02_test.go +++ b/pkg/attestation/renderer/chainloop/v02_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -113,8 +113,6 @@ func mapToStruct(t *testing.T, input map[string]interface{}) *structpb.Struct { } func TestNormalizeMaterial(t *testing.T) { - var emptyMap = make(map[string]string) - testCases := []struct { name string input *intoto.ResourceDescriptor @@ -160,10 +158,13 @@ func TestNormalizeMaterial(t *testing.T) { Content: []byte("bar"), }, want: &NormalizedMaterial{ - Name: "foo", - Type: "STRING", - Value: "bar", - Annotations: emptyMap, + Name: "foo", + Type: "STRING", + Value: "bar", + Annotations: map[string]string{ + "chainloop.material.name": "foo", + "chainloop.material.type": "STRING", + }, }, }, { @@ -195,7 +196,11 @@ func TestNormalizeMaterial(t *testing.T) { Filename: "artifact.tgz", Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, UploadedToCAS: true, - Annotations: emptyMap, + Annotations: map[string]string{ + "chainloop.material.name": "foo", + "chainloop.material.type": "ARTIFACT", + "chainloop.material.cas": "true", + }, }, }, { @@ -219,7 +224,13 @@ func TestNormalizeMaterial(t *testing.T) { Filename: "artifact.tgz", Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, UploadedToCAS: true, - Annotations: map[string]string{"foo": "bar", "bar": "baz"}, + Annotations: map[string]string{ + "foo": "bar", + "bar": "baz", + "chainloop.material.name": "foo", + "chainloop.material.type": "ARTIFACT", + "chainloop.material.cas": "true", + }, }, }, { @@ -243,7 +254,11 @@ func TestNormalizeMaterial(t *testing.T) { Value: "this is an inline material", Hash: &crv1.Hash{Algorithm: "sha256", Hex: "deadbeef"}, EmbeddedInline: true, - Annotations: emptyMap, + Annotations: map[string]string{ + "chainloop.material.name": "foo", + "chainloop.material.type": "ARTIFACT", + "chainloop.material.cas.inline": "true", + }, }, }, { @@ -285,6 +300,28 @@ func TestNormalizeMaterial(t *testing.T) { } } +func TestStructValueToString(t *testing.T) { + testCases := []struct { + name string + input *structpb.Value + want string + }{ + {name: "string value", input: structpb.NewStringValue("hello"), want: "hello"}, + {name: "empty string", input: structpb.NewStringValue(""), want: ""}, + {name: "bool true", input: structpb.NewBoolValue(true), want: "true"}, + {name: "bool false", input: structpb.NewBoolValue(false), want: "false"}, + {name: "number int", input: structpb.NewNumberValue(42), want: "42"}, + {name: "number float", input: structpb.NewNumberValue(3.14), want: "3.14"}, + {name: "null value", input: structpb.NewNullValue(), want: ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, structValueToString(tc.input)) + }) + } +} + func TestPolicyEvaluationsField(t *testing.T) { raw, err := os.ReadFile("testdata/attestation-pe-snake.json") require.NoError(t, err) From 83564f07b0a8a67a4acd28df4291d008f888c6a8 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 18 Mar 2026 08:15:56 +0100 Subject: [PATCH 2/3] fix(attestation): handle struct/list annotation values in structValueToString JSON-marshal StructValue and ListValue types instead of returning an empty string, so annotations stored as complex types are preserved. Signed-off-by: Miguel Martinez Trivino --- pkg/attestation/renderer/chainloop/v02.go | 6 ++++++ pkg/attestation/renderer/chainloop/v02_test.go | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg/attestation/renderer/chainloop/v02.go b/pkg/attestation/renderer/chainloop/v02.go index 20b48680b..41bc657c8 100644 --- a/pkg/attestation/renderer/chainloop/v02.go +++ b/pkg/attestation/renderer/chainloop/v02.go @@ -537,6 +537,12 @@ func structValueToString(v *structpb.Value) string { return strconv.FormatBool(v.GetBoolValue()) case *structpb.Value_NumberValue: return fmt.Sprintf("%g", v.GetNumberValue()) + case *structpb.Value_StructValue, *structpb.Value_ListValue: + b, err := json.Marshal(v.AsInterface()) + if err != nil { + return "" + } + return string(b) default: return "" } diff --git a/pkg/attestation/renderer/chainloop/v02_test.go b/pkg/attestation/renderer/chainloop/v02_test.go index 8caaf2b72..d867170a6 100644 --- a/pkg/attestation/renderer/chainloop/v02_test.go +++ b/pkg/attestation/renderer/chainloop/v02_test.go @@ -313,6 +313,8 @@ func TestStructValueToString(t *testing.T) { {name: "number int", input: structpb.NewNumberValue(42), want: "42"}, {name: "number float", input: structpb.NewNumberValue(3.14), want: "3.14"}, {name: "null value", input: structpb.NewNullValue(), want: ""}, + {name: "list value", input: mustListValue(t, []any{"a", "b"}), want: `["a","b"]`}, + {name: "struct value", input: mustStructValue(t, map[string]any{"k": "v"}), want: `{"k":"v"}`}, } for _, tc := range testCases { @@ -322,6 +324,20 @@ func TestStructValueToString(t *testing.T) { } } +func mustListValue(t *testing.T, items []any) *structpb.Value { + t.Helper() + list, err := structpb.NewList(items) + require.NoError(t, err) + return structpb.NewListValue(list) +} + +func mustStructValue(t *testing.T, fields map[string]any) *structpb.Value { + t.Helper() + s, err := structpb.NewStruct(fields) + require.NoError(t, err) + return structpb.NewStructValue(s) +} + func TestPolicyEvaluationsField(t *testing.T) { raw, err := os.ReadFile("testdata/attestation-pe-snake.json") require.NoError(t, err) From 081c8753ba3eaae58de972c46e0355c3df3afb83 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 18 Mar 2026 08:23:24 +0100 Subject: [PATCH 3/3] fix(plugins): update golden test files for chainloop.* annotations Update plugin SDK summary table golden files to include the newly preserved chainloop.* annotations in material output. Signed-off-by: Miguel Martinez Trivino --- .../plugins/sdk/v1/testdata/attestations/full.md | 10 ++++++++++ .../plugins/sdk/v1/testdata/attestations/full.txt | 10 ++++++++++ .../plugins/sdk/v1/testdata/attestations/truncated.txt | 8 ++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/controlplane/plugins/sdk/v1/testdata/attestations/full.md b/app/controlplane/plugins/sdk/v1/testdata/attestations/full.md index 118e1ee74..8fcea7aa7 100644 --- a/app/controlplane/plugins/sdk/v1/testdata/attestations/full.md +++ b/app/controlplane/plugins/sdk/v1/testdata/attestations/full.md @@ -18,16 +18,26 @@ | Type | CONTAINER_IMAGE | | Value | index.docker.io/bitnami/nginx | | Digest | sha256:264f55a6ff9cec2f4742a9faacc033b29f65c04dd4480e71e23579d484288d61 | +| Annotations | ------ | +| | chainloop.material.name: image | +| | chainloop.material.type: CONTAINER_IMAGE | | Name | skynet-sbom | | Type | SBOM_CYCLONEDX_JSON | | Value | sbom.cyclonedx.json | | Digest | sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c | | Annotations | ------ | +| | chainloop.material.cas: true | +| | chainloop.material.name: skynet-sbom | +| | chainloop.material.type: SBOM_CYCLONEDX_JSON | | | component: nginx | | Name | skynet2-sbom | | Type | SBOM_CYCLONEDX_JSON | | Value | sbom.cyclonedx.json | | Digest | sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c | +| Annotations | ------ | +| | chainloop.material.cas: true | +| | chainloop.material.name: skynet2-sbom | +| | chainloop.material.type: SBOM_CYCLONEDX_JSON | # Environment Variables | Name | Value | | --- | --- | diff --git a/app/controlplane/plugins/sdk/v1/testdata/attestations/full.txt b/app/controlplane/plugins/sdk/v1/testdata/attestations/full.txt index b5b880d1a..b50f16471 100644 --- a/app/controlplane/plugins/sdk/v1/testdata/attestations/full.txt +++ b/app/controlplane/plugins/sdk/v1/testdata/attestations/full.txt @@ -25,18 +25,28 @@ │ Type │ CONTAINER_IMAGE │ │ Value │ index.docker.io/bitnami/nginx │ │ Digest │ sha256:264f55a6ff9cec2f4742a9faacc033b29f65c04dd4480e71e23579d484288d61 │ +│ Annotations │ ------ │ +│ │ chainloop.material.name: image │ +│ │ chainloop.material.type: CONTAINER_IMAGE │ ├─────────────┼─────────────────────────────────────────────────────────────────────────┤ │ Name │ skynet-sbom │ │ Type │ SBOM_CYCLONEDX_JSON │ │ Value │ sbom.cyclonedx.json │ │ Digest │ sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c │ │ Annotations │ ------ │ +│ │ chainloop.material.cas: true │ +│ │ chainloop.material.name: skynet-sbom │ +│ │ chainloop.material.type: SBOM_CYCLONEDX_JSON │ │ │ component: nginx │ ├─────────────┼─────────────────────────────────────────────────────────────────────────┤ │ Name │ skynet2-sbom │ │ Type │ SBOM_CYCLONEDX_JSON │ │ Value │ sbom.cyclonedx.json │ │ Digest │ sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c │ +│ Annotations │ ------ │ +│ │ chainloop.material.cas: true │ +│ │ chainloop.material.name: skynet2-sbom │ +│ │ chainloop.material.type: SBOM_CYCLONEDX_JSON │ └─────────────┴─────────────────────────────────────────────────────────────────────────┘ ┌────────────────────────────────┐ │ Environment Variables │ diff --git a/app/controlplane/plugins/sdk/v1/testdata/attestations/truncated.txt b/app/controlplane/plugins/sdk/v1/testdata/attestations/truncated.txt index fb722c87d..9ac07f696 100644 --- a/app/controlplane/plugins/sdk/v1/testdata/attestations/truncated.txt +++ b/app/controlplane/plugins/sdk/v1/testdata/attestations/truncated.txt @@ -25,12 +25,12 @@ │ Type │ CONTAINER_IMAGE │ │ Value │ index.docker.io/bitnami/nginx │ │ Digest │ sha256:264f55a6ff9cec2f4742a9faacc033b29f65c04dd4480e71e23579d484288d61 │ +│ Annotations │ ------ │ +│ │ chainloop.material.name: image │ +│ │ chainloop.material.type: CONTAINER_IMAGE │ ├─────────────┼─────────────────────────────────────────────────────────────────────────┤ │ Name │ skynet-sbom │ -│ Type │ SBOM_CYCLONEDX_JSON │ -│ Value │ sbom.cyclonedx.json │ -│ Digest │ sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c │ -│ Annotations │ -----... (truncated) +│ Type │ SBOM_... (truncated) Get Full Attestation