Skip to content

Commit 7b6fbd4

Browse files
authored
feat(crafter): Extract evidence information from input (#2503)
1 parent 6fb89be commit 7b6fbd4

File tree

6 files changed

+172
-5
lines changed

6 files changed

+172
-5
lines changed

pkg/attestation/crafter/materials/evidence.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,38 @@ package materials
1717

1818
import (
1919
"context"
20+
"encoding/json"
2021
"fmt"
22+
"os"
2123

2224
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2325
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2426
"github.com/chainloop-dev/chainloop/pkg/casclient"
27+
2528
"github.com/rs/zerolog"
2629
)
2730

31+
const (
32+
// Annotations for evidence metadata that will be extracted if the evidence is in JSON format
33+
annotationEvidenceID = "chainloop.material.evidence.id"
34+
annotationEvidenceSchema = "chainloop.material.evidence.schema"
35+
)
36+
2837
type EvidenceCrafter struct {
2938
*crafterCommon
3039
backend *casclient.CASBackend
3140
}
3241

42+
// customEvidence represents the expected structure of a custom Evidence JSON file
43+
type customEvidence struct {
44+
// ID is a unique identifier for the evidence
45+
ID string `json:"id"`
46+
// Schema is an optional schema reference for the evidence validation
47+
Schema string `json:"schema"`
48+
// Data contains the actual evidence content
49+
Data json.RawMessage `json:"data"`
50+
}
51+
3352
// NewEvidenceCrafter generates a new Evidence material.
3453
// Pieces of evidences represent generic, additional context that don't fit
3554
// 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
4362
}
4463

4564
// Craft will calculate the digest of the artifact, simulate an upload and return the material definition
65+
// If the evidence is in JSON format with id, data (and optionally schema) fields,
66+
// it will extract those as annotations
4667
func (i *EvidenceCrafter) Craft(ctx context.Context, artifactPath string) (*api.Attestation_Material, error) {
47-
return uploadAndCraft(ctx, i.input, i.backend, artifactPath, i.logger)
68+
material, err := uploadAndCraft(ctx, i.input, i.backend, artifactPath, i.logger)
69+
if err != nil {
70+
return nil, err
71+
}
72+
73+
// Try to parse as JSON and extract annotations
74+
i.tryExtractAnnotations(material, artifactPath)
75+
76+
return material, nil
77+
}
78+
79+
// tryExtractAnnotations attempts to parse the evidence as JSON and extract id/schema fields as annotations
80+
func (i *EvidenceCrafter) tryExtractAnnotations(m *api.Attestation_Material, artifactPath string) {
81+
// Read the file content
82+
content, err := os.ReadFile(artifactPath)
83+
if err != nil {
84+
i.logger.Debug().Err(err).Msg("failed to read evidence file for annotation extraction")
85+
return
86+
}
87+
88+
// Try to parse as JSON
89+
var evidence customEvidence
90+
91+
if err := json.Unmarshal(content, &evidence); err != nil {
92+
i.logger.Debug().Err(err).Msg("evidence is not valid JSON, skipping annotation extraction")
93+
return
94+
}
95+
96+
// Check if it has the required structure (id and data fields)
97+
if evidence.ID == "" || len(evidence.Data) == 0 {
98+
i.logger.Debug().Msg("evidence JSON does not have required id and data fields, skipping annotation extraction")
99+
return
100+
}
101+
102+
// Initialize annotations map if needed
103+
if m.Annotations == nil {
104+
m.Annotations = make(map[string]string)
105+
}
106+
107+
// Extract id and schema as annotations
108+
m.Annotations[annotationEvidenceID] = evidence.ID
109+
if evidence.Schema != "" {
110+
m.Annotations[annotationEvidenceSchema] = evidence.Schema
111+
}
112+
113+
i.logger.Debug().Str("id", evidence.ID).Str("schema", evidence.Schema).Msg("extracted evidence annotations")
48114
}

pkg/attestation/crafter/materials/evidence_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,75 @@ func assertEvidenceMaterial(t *testing.T, got *attestationApi.Attestation_Materi
154154
Content: []byte("txt file"),
155155
})
156156
}
157+
158+
func TestEvidenceCraftWithJSONAnnotations(t *testing.T) {
159+
schema := &contractAPI.CraftingSchema_Material{Name: "test", Type: contractAPI.CraftingSchema_Material_EVIDENCE}
160+
161+
l := zerolog.Nop()
162+
163+
testCases := []struct {
164+
name string
165+
filePath string
166+
expectedAnnotations map[string]string
167+
}{
168+
{
169+
name: "JSON with id, data and schema fields extracts annotations",
170+
filePath: "./testdata/evidence-with-id-data-schema.json",
171+
expectedAnnotations: map[string]string{
172+
"chainloop.material.evidence.id": "custom-evidence-123",
173+
"chainloop.material.evidence.schema": "https://example.com/schema/v1",
174+
},
175+
},
176+
{
177+
name: "JSON with id and data but no schema field extracts only id",
178+
filePath: "./testdata/evidence-with-id-data-no-schema.json",
179+
expectedAnnotations: map[string]string{
180+
"chainloop.material.evidence.id": "custom-evidence-456",
181+
},
182+
},
183+
{
184+
name: "JSON without required structure does not extract annotations",
185+
filePath: "./testdata/evidence-invalid-structure.json",
186+
expectedAnnotations: nil,
187+
},
188+
{
189+
name: "Non-JSON file does not extract annotations",
190+
filePath: "./testdata/simple.txt",
191+
expectedAnnotations: nil,
192+
},
193+
}
194+
195+
for _, tc := range testCases {
196+
t.Run(tc.name, func(t *testing.T) {
197+
assert := assert.New(t)
198+
199+
// Create a new mock uploader for each test case
200+
uploader := mUploader.NewUploader(t)
201+
uploader.On("UploadFile", context.TODO(), tc.filePath).
202+
Return(&casclient.UpDownStatus{
203+
Digest: "deadbeef",
204+
Filename: tc.filePath,
205+
}, nil)
206+
207+
backend := &casclient.CASBackend{Uploader: uploader}
208+
209+
crafter, err := materials.NewEvidenceCrafter(schema, backend, &l)
210+
require.NoError(t, err)
211+
212+
got, err := crafter.Craft(context.TODO(), tc.filePath)
213+
assert.NoError(err)
214+
assert.Equal(contractAPI.CraftingSchema_Material_EVIDENCE.String(), got.MaterialType.String())
215+
216+
if tc.expectedAnnotations == nil {
217+
assert.Empty(got.Annotations)
218+
} else {
219+
assert.NotNil(got.Annotations)
220+
for key, value := range tc.expectedAnnotations {
221+
assert.Equal(value, got.Annotations[key])
222+
}
223+
// Ensure no extra keys are present beyond expected
224+
assert.Len(got.Annotations, len(tc.expectedAnnotations))
225+
}
226+
})
227+
}
228+
}

pkg/attestation/crafter/materials/materials.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ import (
3535
"google.golang.org/protobuf/types/known/timestamppb"
3636
)
3737

38-
const AnnotationToolNameKey = "chainloop.material.tool.name"
39-
const AnnotationToolVersionKey = "chainloop.material.tool.version"
40-
const AnnotationToolsKey = "chainloop.material.tools"
38+
const (
39+
AnnotationToolNameKey = "chainloop.material.tool.name"
40+
AnnotationToolVersionKey = "chainloop.material.tool.version"
41+
AnnotationToolsKey = "chainloop.material.tools"
42+
)
4143

4244
// IsLegacyAnnotation returns true if the annotation key is a legacy annotation
4345
func IsLegacyAnnotation(key string) bool {
@@ -50,7 +52,7 @@ type Tool struct {
5052
Version string
5153
}
5254

53-
// SetToolsAnnotations sets the tools annotation as a JSON array in "name@version" format
55+
// SetToolsAnnotation sets the tools annotation as a JSON array in "name@version" format
5456
func SetToolsAnnotation(m *api.Attestation_Material, tools []Tool) {
5557
if len(tools) == 0 {
5658
return
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"status": "approved",
3+
"approver": "john.doe@example.com",
4+
"no_id_or_data": true
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "custom-evidence-456",
3+
"data": {
4+
"status": "rejected",
5+
"approver": "jane.doe@example.com",
6+
"timestamp": "2025-10-30T11:00:00Z"
7+
}
8+
}
9+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"id": "custom-evidence-123",
3+
"schema": "https://example.com/schema/v1",
4+
"data": {
5+
"status": "approved",
6+
"approver": "john.doe@example.com",
7+
"timestamp": "2025-10-30T10:00:00Z",
8+
"details": {
9+
"review_type": "security",
10+
"findings": ["no issues found"]
11+
}
12+
}
13+
}

0 commit comments

Comments
 (0)