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..8df8cd069 100644 --- a/pkg/attestation/crafter/materials/evidence_test.go +++ b/pkg/attestation/crafter/materials/evidence_test.go @@ -154,3 +154,75 @@ func assertEvidenceMaterial(t *testing.T, got *attestationApi.Attestation_Materi Content: []byte("txt file"), }) } + +func TestEvidenceCraftWithJSONAnnotations(t *testing.T) { + schema := &contractAPI.CraftingSchema_Material{Name: "test", Type: contractAPI.CraftingSchema_Material_EVIDENCE} + + l := zerolog.Nop() + + 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, + }, + } + + 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)) + } + }) + } +} 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"] + } + } +}