Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion pkg/attestation/crafter/materials/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), ...
Expand All @@ -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")
}
72 changes: 72 additions & 0 deletions pkg/attestation/crafter/materials/evidence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
})
}
}
10 changes: 6 additions & 4 deletions pkg/attestation/crafter/materials/materials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"status": "approved",
"approver": "john.doe@example.com",
"no_id_or_data": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "custom-evidence-456",
"data": {
"status": "rejected",
"approver": "jane.doe@example.com",
"timestamp": "2025-10-30T11:00:00Z"
}
}

Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
Loading