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 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..41bc657c8 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,25 @@ 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()) + 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 58c497a11..d867170a6 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,44 @@ 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: ""}, + {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 { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, structValueToString(tc.input)) + }) + } +} + +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)