From c706eb7bb26936cd3b46db8d3262ae9aaee18374 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 30 Oct 2025 15:55:52 +0100 Subject: [PATCH 1/2] feat(crafter): Extract evidence information from input Signed-off-by: Javier Rodriguez --- pkg/attestation/crafter/materials/evidence.go | 68 ++++++++++++++++++- .../crafter/materials/evidence_test.go | 62 +++++++++++++++++ .../crafter/materials/materials.go | 10 +-- .../testdata/evidence-invalid-structure.json | 5 ++ .../evidence-with-id-data-no-schema.json | 9 +++ .../evidence-with-id-data-schema.json | 13 ++++ 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 pkg/attestation/crafter/materials/testdata/evidence-invalid-structure.json create mode 100644 pkg/attestation/crafter/materials/testdata/evidence-with-id-data-no-schema.json create mode 100644 pkg/attestation/crafter/materials/testdata/evidence-with-id-data-schema.json diff --git a/pkg/attestation/crafter/materials/evidence.go b/pkg/attestation/crafter/materials/evidence.go index b957ec85b..414afb21a 100644 --- a/pkg/attestation/crafter/materials/evidence.go +++ b/pkg/attestation/crafter/materials/evidence.go @@ -17,19 +17,38 @@ package materials import ( "context" + "encoding/json" "fmt" + "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/rs/zerolog" ) +const ( + // Annotations for evidence metadata that will be extracted if the evidence is in JSON format + annotationEvidenceID = "chainloop.material.evidence.id" + annotationEvidenceSchema = "chainloop.material.evidence.schema" +) + type EvidenceCrafter struct { *crafterCommon backend *casclient.CASBackend } +// customEvidence represents the expected structure of a custom Evidence JSON file +type customEvidence struct { + // ID is a unique identifier for the evidence + ID string `json:"id"` + // Schema is an optional schema reference for the evidence validation + Schema string `json:"schema"` + // Data contains the actual evidence content + Data json.RawMessage `json:"data"` +} + // NewEvidenceCrafter generates a new Evidence material. // Pieces of evidences represent generic, additional context that don't fit // into one of the well known material types. For example, a custom approval report (in json), ... @@ -43,6 +62,53 @@ func NewEvidenceCrafter(schema *schemaapi.CraftingSchema_Material, backend *casc } // Craft will calculate the digest of the artifact, simulate an upload and return the material definition +// If the evidence is in JSON format with id, data (and optionally schema) fields, +// it will extract those as annotations func (i *EvidenceCrafter) Craft(ctx context.Context, artifactPath string) (*api.Attestation_Material, error) { - return uploadAndCraft(ctx, i.input, i.backend, artifactPath, i.logger) + material, err := uploadAndCraft(ctx, i.input, i.backend, artifactPath, i.logger) + if err != nil { + return nil, err + } + + // Try to parse as JSON and extract annotations + i.tryExtractAnnotations(material, artifactPath) + + return material, nil +} + +// tryExtractAnnotations attempts to parse the evidence as JSON and extract id/schema fields as annotations +func (i *EvidenceCrafter) tryExtractAnnotations(m *api.Attestation_Material, artifactPath string) { + // Read the file content + content, err := os.ReadFile(artifactPath) + if err != nil { + i.logger.Debug().Err(err).Msg("failed to read evidence file for annotation extraction") + return + } + + // Try to parse as JSON + var evidence customEvidence + + if err := json.Unmarshal(content, &evidence); err != nil { + i.logger.Debug().Err(err).Msg("evidence is not valid JSON, skipping annotation extraction") + return + } + + // Check if it has the required structure (id and data fields) + if evidence.ID == "" || len(evidence.Data) == 0 { + i.logger.Debug().Msg("evidence JSON does not have required id and data fields, skipping annotation extraction") + return + } + + // Initialize annotations map if needed + if m.Annotations == nil { + m.Annotations = make(map[string]string) + } + + // Extract id and schema as annotations + m.Annotations[annotationEvidenceID] = evidence.ID + if evidence.Schema != "" { + m.Annotations[annotationEvidenceSchema] = evidence.Schema + } + + i.logger.Debug().Str("id", evidence.ID).Str("schema", evidence.Schema).Msg("extracted evidence annotations") } diff --git a/pkg/attestation/crafter/materials/evidence_test.go b/pkg/attestation/crafter/materials/evidence_test.go index f57b6292f..a4afa53b2 100644 --- a/pkg/attestation/crafter/materials/evidence_test.go +++ b/pkg/attestation/crafter/materials/evidence_test.go @@ -154,3 +154,65 @@ func assertEvidenceMaterial(t *testing.T, got *attestationApi.Attestation_Materi Content: []byte("txt file"), }) } + +func TestEvidenceCraftWithJSONAnnotations(t *testing.T) { + assert := assert.New(t) + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_EVIDENCE, + } + l := zerolog.Nop() + backend := &casclient.CASBackend{} + + t.Run("JSON with id, data and schema fields extracts annotations", func(t *testing.T) { + crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), "./testdata/evidence-with-id-data-schema.json") + assert.NoError(err) + assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) + + // Check annotations were extracted + assert.NotNil(got.Annotations) + assert.Equal("custom-evidence-123", got.Annotations["chainloop.evidence.id"]) + assert.Equal("https://example.com/schema/v1", got.Annotations["chainloop.evidence.schema"]) + }) + + t.Run("JSON with id and data but no schema field extracts only id", func(t *testing.T) { + crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), "./testdata/evidence-with-id-data-no-schema.json") + assert.NoError(err) + assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) + + // Check annotations were extracted + assert.NotNil(got.Annotations) + assert.Equal("custom-evidence-456", got.Annotations["chainloop.evidence.id"]) + assert.NotContains(got.Annotations, "chainloop.evidence.schema") + }) + + t.Run("JSON without required structure does not extract annotations", func(t *testing.T) { + crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), "./testdata/evidence-invalid-structure.json") + assert.NoError(err) + assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) + + // Check no annotations were extracted + assert.Empty(got.Annotations) + }) + + t.Run("Non-JSON file does not extract annotations", func(t *testing.T) { + crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), "./testdata/simple.txt") + assert.NoError(err) + assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) + + // Check no annotations were extracted (non-JSON) + assert.Empty(got.Annotations) + }) +} diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 98349377f..d8b216c9a 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -35,9 +35,11 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -const AnnotationToolNameKey = "chainloop.material.tool.name" -const AnnotationToolVersionKey = "chainloop.material.tool.version" -const AnnotationToolsKey = "chainloop.material.tools" +const ( + AnnotationToolNameKey = "chainloop.material.tool.name" + AnnotationToolVersionKey = "chainloop.material.tool.version" + AnnotationToolsKey = "chainloop.material.tools" +) // IsLegacyAnnotation returns true if the annotation key is a legacy annotation func IsLegacyAnnotation(key string) bool { @@ -50,7 +52,7 @@ type Tool struct { Version string } -// SetToolsAnnotations sets the tools annotation as a JSON array in "name@version" format +// SetToolsAnnotation sets the tools annotation as a JSON array in "name@version" format func SetToolsAnnotation(m *api.Attestation_Material, tools []Tool) { if len(tools) == 0 { return diff --git a/pkg/attestation/crafter/materials/testdata/evidence-invalid-structure.json b/pkg/attestation/crafter/materials/testdata/evidence-invalid-structure.json new file mode 100644 index 000000000..95814198a --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/evidence-invalid-structure.json @@ -0,0 +1,5 @@ +{ + "status": "approved", + "approver": "john.doe@example.com", + "no_id_or_data": true +} diff --git a/pkg/attestation/crafter/materials/testdata/evidence-with-id-data-no-schema.json b/pkg/attestation/crafter/materials/testdata/evidence-with-id-data-no-schema.json new file mode 100644 index 000000000..c8de363eb --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/evidence-with-id-data-no-schema.json @@ -0,0 +1,9 @@ +{ + "id": "custom-evidence-456", + "data": { + "status": "rejected", + "approver": "jane.doe@example.com", + "timestamp": "2025-10-30T11:00:00Z" + } +} + diff --git a/pkg/attestation/crafter/materials/testdata/evidence-with-id-data-schema.json b/pkg/attestation/crafter/materials/testdata/evidence-with-id-data-schema.json new file mode 100644 index 000000000..010231434 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/evidence-with-id-data-schema.json @@ -0,0 +1,13 @@ +{ + "id": "custom-evidence-123", + "schema": "https://example.com/schema/v1", + "data": { + "status": "approved", + "approver": "john.doe@example.com", + "timestamp": "2025-10-30T10:00:00Z", + "details": { + "review_type": "security", + "findings": ["no issues found"] + } + } +} From 84a548d1cd38dc5d8ee656c25fc8180fae4c0739 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 30 Oct 2025 16:29:44 +0100 Subject: [PATCH 2/2] fix tests Signed-off-by: Javier Rodriguez --- .../crafter/materials/evidence_test.go | 122 ++++++++++-------- 1 file changed, 66 insertions(+), 56 deletions(-) diff --git a/pkg/attestation/crafter/materials/evidence_test.go b/pkg/attestation/crafter/materials/evidence_test.go index a4afa53b2..8df8cd069 100644 --- a/pkg/attestation/crafter/materials/evidence_test.go +++ b/pkg/attestation/crafter/materials/evidence_test.go @@ -156,63 +156,73 @@ func assertEvidenceMaterial(t *testing.T, got *attestationApi.Attestation_Materi } func TestEvidenceCraftWithJSONAnnotations(t *testing.T) { - assert := assert.New(t) - schema := &contractAPI.CraftingSchema_Material{ - Name: "test", - Type: contractAPI.CraftingSchema_Material_EVIDENCE, - } - l := zerolog.Nop() - backend := &casclient.CASBackend{} - - t.Run("JSON with id, data and schema fields extracts annotations", func(t *testing.T) { - crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) - require.NoError(t, err) - - got, err := crafter.Craft(context.TODO(), "./testdata/evidence-with-id-data-schema.json") - assert.NoError(err) - assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) - - // Check annotations were extracted - assert.NotNil(got.Annotations) - assert.Equal("custom-evidence-123", got.Annotations["chainloop.evidence.id"]) - assert.Equal("https://example.com/schema/v1", got.Annotations["chainloop.evidence.schema"]) - }) + schema := &contractAPI.CraftingSchema_Material{Name: "test", Type: contractAPI.CraftingSchema_Material_EVIDENCE} - t.Run("JSON with id and data but no schema field extracts only id", func(t *testing.T) { - crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) - require.NoError(t, err) - - got, err := crafter.Craft(context.TODO(), "./testdata/evidence-with-id-data-no-schema.json") - assert.NoError(err) - assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) - - // Check annotations were extracted - assert.NotNil(got.Annotations) - assert.Equal("custom-evidence-456", got.Annotations["chainloop.evidence.id"]) - assert.NotContains(got.Annotations, "chainloop.evidence.schema") - }) - - t.Run("JSON without required structure does not extract annotations", func(t *testing.T) { - crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) - require.NoError(t, err) - - got, err := crafter.Craft(context.TODO(), "./testdata/evidence-invalid-structure.json") - assert.NoError(err) - assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) - - // Check no annotations were extracted - assert.Empty(got.Annotations) - }) - - t.Run("Non-JSON file does not extract annotations", func(t *testing.T) { - crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) - require.NoError(t, err) + l := zerolog.Nop() - got, err := crafter.Craft(context.TODO(), "./testdata/simple.txt") - assert.NoError(err) - assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) + testCases := []struct { + name string + filePath string + expectedAnnotations map[string]string + }{ + { + name: "JSON with id, data and schema fields extracts annotations", + filePath: "./testdata/evidence-with-id-data-schema.json", + expectedAnnotations: map[string]string{ + "chainloop.material.evidence.id": "custom-evidence-123", + "chainloop.material.evidence.schema": "https://example.com/schema/v1", + }, + }, + { + name: "JSON with id and data but no schema field extracts only id", + filePath: "./testdata/evidence-with-id-data-no-schema.json", + expectedAnnotations: map[string]string{ + "chainloop.material.evidence.id": "custom-evidence-456", + }, + }, + { + name: "JSON without required structure does not extract annotations", + filePath: "./testdata/evidence-invalid-structure.json", + expectedAnnotations: nil, + }, + { + name: "Non-JSON file does not extract annotations", + filePath: "./testdata/simple.txt", + expectedAnnotations: nil, + }, + } - // Check no annotations were extracted (non-JSON) - assert.Empty(got.Annotations) - }) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert := assert.New(t) + + // Create a new mock uploader for each test case + uploader := mUploader.NewUploader(t) + uploader.On("UploadFile", context.TODO(), tc.filePath). + Return(&casclient.UpDownStatus{ + Digest: "deadbeef", + Filename: tc.filePath, + }, nil) + + backend := &casclient.CASBackend{Uploader: uploader} + + crafter, err := materials.NewEvidenceCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + assert.NoError(err) + assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String()) + + if tc.expectedAnnotations == nil { + assert.Empty(got.Annotations) + } else { + assert.NotNil(got.Annotations) + for key, value := range tc.expectedAnnotations { + assert.Equal(value, got.Annotations[key]) + } + // Ensure no extra keys are present beyond expected + assert.Len(got.Annotations, len(tc.expectedAnnotations)) + } + }) + } }