diff --git a/app/cli/cmd/attestation_push.go b/app/cli/cmd/attestation_push.go index c15d96c0f..899e8408b 100644 --- a/app/cli/cmd/attestation_push.go +++ b/app/cli/cmd/attestation_push.go @@ -56,7 +56,7 @@ func newAttestationPushCmd() *cobra.Command { return fmt.Errorf("getting executable information: %w", err) } a := action.NewAttestationPush(&action.AttestationPushOpts{ - ActionsOpts: actionOpts, KeyPath: pkPath, CLIversion: info.Version, CLIDigest: info.Digest, + ActionsOpts: actionOpts, KeyPath: pkPath, CLIVersion: info.Version, CLIDigest: info.Digest, }) res, err := a.Run() diff --git a/app/cli/internal/action/attestation_push.go b/app/cli/internal/action/attestation_push.go index 9f9508ee7..8f0a3cbb4 100644 --- a/app/cli/internal/action/attestation_push.go +++ b/app/cli/internal/action/attestation_push.go @@ -28,7 +28,7 @@ import ( type AttestationPushOpts struct { *ActionsOpts - KeyPath, CLIversion, CLIDigest string + KeyPath, CLIVersion, CLIDigest string } type AttestationPush struct { @@ -42,7 +42,7 @@ func NewAttestationPush(cfg *AttestationPushOpts) *AttestationPush { ActionsOpts: cfg.ActionsOpts, c: crafter.NewCrafter(crafter.WithLogger(&cfg.Logger)), keyPath: cfg.KeyPath, - cliVersion: cfg.CLIversion, + cliVersion: cfg.CLIVersion, cliDigest: cfg.CLIDigest, } } @@ -64,7 +64,7 @@ func (action *AttestationPush) Run() (interface{}, error) { action.Logger.Debug().Msg("validation completed") - renderer, err := renderer.NewAttestationRenderer(action.c.CraftingState, action.keyPath, action.cliVersion, action.cliDigest, &action.Logger) + renderer, err := renderer.NewAttestationRenderer(action.c.CraftingState, action.keyPath, action.cliVersion, action.cliDigest, renderer.WithLogger(action.Logger)) if err != nil { return nil, err } diff --git a/internal/attestation/renderer/chainloop.go b/internal/attestation/renderer/chainloop.go index 17e41eb66..595245097 100644 --- a/internal/attestation/renderer/chainloop.go +++ b/internal/attestation/renderer/chainloop.go @@ -19,6 +19,7 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "sort" "strings" "time" @@ -95,20 +96,23 @@ type ChainloopMaintainer struct { } type ChainloopRenderer struct { - att *v1.Attestation - schema *schemaapi.CraftingSchema + att *v1.Attestation + builder *builderInfo +} + +type builderInfo struct { version, digest string } -func newChainloopRenderer(att *v1.Attestation, schema *schemaapi.CraftingSchema, version, digest string) *ChainloopRenderer { - return &ChainloopRenderer{att, schema, version, digest} +func newChainloopRenderer(att *v1.Attestation, builderVersion, builderDigest string) *ChainloopRenderer { + return &ChainloopRenderer{att, &builderInfo{builderVersion, builderDigest}} } func (r *ChainloopRenderer) Predicate() (interface{}, error) { return ChainloopProvenancePredicateV1{ Materials: outputChainloopMaterials(r.att, false), BuildType: chainloopBuildType, - Builder: &slsacommon.ProvenanceBuilder{ID: fmt.Sprintf(builderIDFmt, r.version, r.digest)}, + Builder: &slsacommon.ProvenanceBuilder{ID: fmt.Sprintf(builderIDFmt, r.builder.version, r.builder.digest)}, Metadata: getChainloopMeta(r.att), Env: r.att.EnvVars, RunnerType: r.att.GetRunnerType().String(), @@ -167,8 +171,19 @@ func (r *ChainloopRenderer) Header() (*in_toto.StatementHeader, error) { } func outputChainloopMaterials(att *v1.Attestation, onlyOutput bool) []*ChainloopProvenanceMaterial { + // Sort material keys to stabilize output + keys := make([]string, 0, len(att.GetMaterials())) + for k := range att.GetMaterials() { + keys = append(keys, k) + } + + sort.Strings(keys) + res := []*ChainloopProvenanceMaterial{} - for mdefName, mdef := range att.GetMaterials() { + materials := att.GetMaterials() + for _, mdefName := range keys { + mdef := materials[mdefName] + var value, digest string artifactType := mdef.MaterialType var isOutput bool diff --git a/internal/attestation/renderer/chainloop_test.go b/internal/attestation/renderer/chainloop_test.go index 40410b822..bf0f7c2cd 100644 --- a/internal/attestation/renderer/chainloop_test.go +++ b/internal/attestation/renderer/chainloop_test.go @@ -21,11 +21,67 @@ import ( "os" "testing" + api "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + "github.com/in-toto/in-toto-golang/in_toto" "github.com/secure-systems-lab/go-securesystemslib/dsse" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" ) +func TestRender(t *testing.T) { + testCases := []struct { + name string + sourcePath string + outputPath string + }{ + { + name: "render v0.1", + sourcePath: "testdata/attestation.source.json", + outputPath: "testdata/attestation.output.v0.1.json", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Load expected resulting output + wantRaw, err := os.ReadFile(tc.outputPath) + require.NoError(t, err) + + var want *in_toto.Statement + err = json.Unmarshal(wantRaw, &want) + require.NoError(t, err) + + // Initialize renderer + state := &api.CraftingState{} + stateRaw, err := os.ReadFile(tc.sourcePath) + require.NoError(t, err) + + err = protojson.Unmarshal(stateRaw, state) + require.NoError(t, err) + + renderer, err := NewAttestationRenderer(state, "", "dev", "sha256:59e14f1a9de709cdd0e91c36b33e54fcca95f7dba1dc7169a7f81986e02108e5") + require.NoError(t, err) + + // Compare header + gotHeader, err := renderer.renderer.Header() + assert.NoError(t, err) + assert.Equal(t, want.Type, gotHeader.Type) + assert.Equal(t, want.Subject, gotHeader.Subject) + assert.Equal(t, want.PredicateType, gotHeader.PredicateType) + + // Compare predicate + gotPredicateI, err := renderer.renderer.Predicate() + assert.NoError(t, err) + gotPredicate := gotPredicateI.(ChainloopProvenancePredicateV1) + wantPredicate, err := extractPredicateV1(want) + wantPredicate.Metadata.FinishedAt = gotPredicate.Metadata.FinishedAt + assert.NoError(t, err) + assert.EqualValues(t, wantPredicate, &gotPredicate) + }) + } +} + func TestExtractPredicate(t *testing.T) { testCases := []struct { name string diff --git a/internal/attestation/renderer/renderer.go b/internal/attestation/renderer/renderer.go index 7d7c1cabd..3e42b0367 100644 --- a/internal/attestation/renderer/renderer.go +++ b/internal/attestation/renderer/renderer.go @@ -35,7 +35,7 @@ import ( ) type AttestationRenderer struct { - logger *zerolog.Logger + logger zerolog.Logger signingKeyPath string att *v1.Attestation renderer r @@ -46,17 +46,31 @@ type r interface { Predicate() (interface{}, error) } -func NewAttestationRenderer(state *v1.CraftingState, keyPath, builderVersion, builderDigest string, logger *zerolog.Logger) (*AttestationRenderer, error) { +type Opt func(*AttestationRenderer) + +func WithLogger(logger zerolog.Logger) Opt { + return func(ar *AttestationRenderer) { + ar.logger = logger + } +} + +func NewAttestationRenderer(state *v1.CraftingState, keyPath, builderVersion, builderDigest string, opts ...Opt) (*AttestationRenderer, error) { if state.GetAttestation() == nil { return nil, errors.New("attestation not initialized") } - return &AttestationRenderer{ - logger: logger, + r := &AttestationRenderer{ + logger: zerolog.Nop(), signingKeyPath: keyPath, att: state.GetAttestation(), - renderer: newChainloopRenderer(state.GetAttestation(), state.GetInputSchema(), builderVersion, builderDigest), - }, nil + renderer: newChainloopRenderer(state.GetAttestation(), builderVersion, builderDigest), + } + + for _, opt := range opts { + opt(r) + } + + return r, nil } // Attestation (dsee envelope) -> { message: { Statement(in-toto): [subject, predicate] }, signature: "sig" }. diff --git a/internal/attestation/renderer/testdata/attestation.output.v0.1.json b/internal/attestation/renderer/testdata/attestation.output.v0.1.json new file mode 100644 index 000000000..421eb2d17 --- /dev/null +++ b/internal/attestation/renderer/testdata/attestation.output.v0.1.json @@ -0,0 +1,79 @@ +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "chainloop.dev/attestation/v0.1", + "subject": [ + { + "name": "chainloop.dev/workflow/foo", + "digest": { + "sha256": "6cd649c6105e12a235510a585371eb69c8c9ee797d8dc80d30695828ca116b00" + } + }, + { + "name": "index.docker.io/bitnami/nginx", + "digest": { + "sha256": "580ac09da7771920dfd0c214964e7bfe4c27903bcbe075769a4044a67c9a390a" + } + } + ], + "predicate": { + "metadata": { + "name": "foo", + "project": "bar", + "team": "", + "initializedAt": "2023-05-03T17:22:12.743426076Z", + "finishedAt": "2023-05-03T19:27:51.352850152+02:00", + "workflowRunID": "", + "workflowID": "54ea7c5c-7592-48ac-9a9f-084b72447184" + }, + "materials": [ + { + "name": "build-ref", + "type": "STRING", + "material": { + "stringVal": "a-string" + } + }, + { + "name": "rootfs", + "type": "ARTIFACT", + "material": { + "slsa": { + "uri": "Makefile", + "digest": { + "sha256": "cfc7d8e24d21ade921d720228ad1693de59dab45ff679606940be75b7bf660dc" + } + } + } + }, + { + "name": "skynet-control-plane", + "type": "CONTAINER_IMAGE", + "material": { + "slsa": { + "uri": "index.docker.io/bitnami/nginx", + "digest": { + "sha256": "580ac09da7771920dfd0c214964e7bfe4c27903bcbe075769a4044a67c9a390a" + } + } + } + }, + { + "name": "skynet-sbom", + "type": "SBOM_CYCLONEDX_JSON", + "material": { + "slsa": { + "uri": "sbom.cyclonedx.json", + "digest": { + "sha256": "16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c" + } + } + } + } + ], + "builder": { + "id": "chainloop.dev/cli/dev@sha256:59e14f1a9de709cdd0e91c36b33e54fcca95f7dba1dc7169a7f81986e02108e5" + }, + "buildType": "chainloop.dev/workflowrun/v0.1", + "runnerType": "GITHUB_ACTION" + } +} diff --git a/internal/attestation/renderer/testdata/attestation.source.json b/internal/attestation/renderer/testdata/attestation.source.json new file mode 100644 index 000000000..590b2a7ba --- /dev/null +++ b/internal/attestation/renderer/testdata/attestation.source.json @@ -0,0 +1,84 @@ +{ + "inputSchema": { + "schemaVersion": "v1", + "materials": [ + { + "type": "CONTAINER_IMAGE", + "name": "skynet-control-plane", + "output": true + }, + { + "type": "ARTIFACT", + "name": "rootfs" + }, + { + "type": "ARTIFACT", + "name": "dockerfile", + "optional": true + }, + { + "type": "STRING", + "name": "build-ref" + }, + { + "type": "SBOM_CYCLONEDX_JSON", + "name": "skynet-sbom" + } + ], + "envAllowList": [ + "CUSTOM_VAR" + ], + "runner": { + "type": "GITHUB_ACTION" + } + }, + "attestation": { + "initializedAt": "2023-05-03T17:22:12.743426076Z", + "workflow": { + "name": "foo", + "project": "bar", + "workflowId": "54ea7c5c-7592-48ac-9a9f-084b72447184", + "schemaRevision": "1" + }, + "materials": { + "build-ref": { + "string": { + "id": "build-ref", + "value": "a-string" + }, + "addedAt": "2023-05-03T17:23:27.113091137Z", + "materialType": "STRING" + }, + "rootfs": { + "artifact": { + "id": "rootfs", + "name": "Makefile", + "digest": "sha256:cfc7d8e24d21ade921d720228ad1693de59dab45ff679606940be75b7bf660dc" + }, + "addedAt": "2023-05-03T17:23:13.548426342Z", + "materialType": "ARTIFACT" + }, + "skynet-control-plane": { + "containerImage": { + "id": "skynet-control-plane", + "name": "index.docker.io/bitnami/nginx", + "digest": "sha256:580ac09da7771920dfd0c214964e7bfe4c27903bcbe075769a4044a67c9a390a", + "isSubject": true + }, + "addedAt": "2023-05-03T17:22:49.616972571Z", + "materialType": "CONTAINER_IMAGE" + }, + "skynet-sbom": { + "artifact": { + "id": "skynet-sbom", + "name": "sbom.cyclonedx.json", + "digest": "sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c" + }, + "addedAt": "2023-05-03T17:24:31.956266292Z", + "materialType": "SBOM_CYCLONEDX_JSON" + } + }, + "runnerType": "GITHUB_ACTION" + }, + "dryRun": true +} \ No newline at end of file