From 751a61d566d5fb6f64222948a516408ad1533cd1 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 9 Oct 2026 09:15:55 +0200 Subject: [PATCH 1/2] feat(runners): record a given commit verification in the sandbox runner The CHAINLOOP_SANDBOX runner gets no SCM token, so it cannot ask the provider whether the attested commit is signed. The platform can verify the commit before it starts the sandbox. The runner now reads that result from CHAINLOOP_SANDBOX_COMMIT_VERIFICATION and returns it only when its commit hash is the attested commit. A malformed value records the unavailable status. CommitVerification gets JSON tags, and its status encodes as a name, so the platform and the CLI share one format. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 0ebed99f-c817-4242-9a55-3c64202ac7f3 --- .../crafter/runners/chainloopsandbox.go | 43 +++++- .../crafter/runners/chainloopsandbox_test.go | 123 ++++++++++++++++++ .../commitverification/commitverification.go | 56 +++++++- .../commitverification_test.go | 79 +++++++++++ 4 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 pkg/attestation/crafter/runners/commitverification/commitverification_test.go diff --git a/pkg/attestation/crafter/runners/chainloopsandbox.go b/pkg/attestation/crafter/runners/chainloopsandbox.go index 46435abc2..b1d14b73a 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox.go @@ -17,7 +17,10 @@ package runners import ( "context" + "encoding/json" + "fmt" "os" + "strings" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" @@ -25,6 +28,19 @@ import ( const chainloopSandboxEnvVar = "CHAINLOOP_SANDBOX" +// ChainloopSandboxCommitVerificationEnvVar holds a SandboxCommitVerification as JSON. +// The sandbox gets no SCM token, so the platform verifies the commit before it starts +// the sandbox and gives the result to the runner in this variable. +const ChainloopSandboxCommitVerificationEnvVar = "CHAINLOOP_SANDBOX_COMMIT_VERIFICATION" + +// SandboxCommitVerification is a commit verification result and the commit that it applies to. +type SandboxCommitVerification struct { + // Commit is the full hash of the verified commit. + Commit string `json:"commit"` + // Verification is the result of the verification. + Verification *commitverification.CommitVerification `json:"verification"` +} + type ChainloopSandbox struct { *Generic } @@ -67,8 +83,31 @@ func (r *ChainloopSandbox) Environment() RunnerEnvironment { return Unknown } -func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, _ string) *commitverification.CommitVerification { - return nil +// VerifyCommitSignature returns the verification that the platform gave in +// ChainloopSandboxCommitVerificationEnvVar. It returns nil when no verification was +// given, or when the verification is for a different commit than commitHash. +func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, commitHash string) *commitverification.CommitVerification { + raw := os.Getenv(ChainloopSandboxCommitVerificationEnvVar) + if raw == "" || commitHash == "" { + return nil + } + + var given SandboxCommitVerification + if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil { + return &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnavailable, + Reason: fmt.Sprintf("invalid value in %s", ChainloopSandboxCommitVerificationEnvVar), + } + } + + // The result is about one commit only. Never apply it to the commit of another + // checkout, and never match an abbreviated hash. + if !strings.EqualFold(given.Commit, commitHash) { + return nil + } + + return given.Verification } func (r *ChainloopSandbox) Report(_ []byte, _ string) error { diff --git a/pkg/attestation/crafter/runners/chainloopsandbox_test.go b/pkg/attestation/crafter/runners/chainloopsandbox_test.go index 4ee653655..1b6530a8a 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox_test.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox_test.go @@ -16,8 +16,11 @@ package runners import ( + "context" + "encoding/json" "testing" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" "github.com/stretchr/testify/suite" ) @@ -90,6 +93,126 @@ func (s *chainloopSandboxSuite) TestRunnerName() { s.Equal("CHAINLOOP_SANDBOX", s.runner.ID().String()) } +func (s *chainloopSandboxSuite) TestVerifyCommitSignature() { + const ( + commit = "ea3a974660a030f0314aa1505a64792bf2633b33" + otherCommit = "0d7bb4b1e7b3f5d3e5c5f0f37a2c9a1f7f8b9c10" + platform = "gitlab" + ) + + verified := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusVerified, + Reason: "Commit signed and verified", + Platform: platform, + KeyID: "1", + SignatureAlgorithm: "PGP", + } + verifiedJSON := `{"attempted":true,"status":"verified","reason":"Commit signed and verified","platform":"gitlab","key_id":"1","signature_algorithm":"PGP"}` + + invalid := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnavailable, + Reason: "invalid value in CHAINLOOP_SANDBOX_COMMIT_VERIFICATION", + } + + testCases := []struct { + name string + env string + commit string + want *commitverification.CommitVerification + }{ + { + name: "no verification provided", + commit: commit, + }, + { + name: "verification of the attested commit", + env: `{"commit":"` + commit + `","verification":` + verifiedJSON + `}`, + commit: commit, + want: verified, + }, + { + name: "commit hash in upper case", + env: `{"commit":"EA3A974660A030F0314AA1505A64792BF2633B33","verification":` + verifiedJSON + `}`, + commit: commit, + want: verified, + }, + { + name: "commit not signed", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"not_applicable","reason":"Commit is not signed","platform":"gitlab"}}`, + commit: commit, + want: &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusNotApplicable, + Reason: "Commit is not signed", + Platform: platform, + }, + }, + { + name: "verification of another commit", + env: `{"commit":"` + otherCommit + `","verification":` + verifiedJSON + `}`, + commit: commit, + }, + { + name: "abbreviated commit hash", + env: `{"commit":"ea3a974","verification":` + verifiedJSON + `}`, + commit: commit, + }, + { + name: "no commit to verify", + env: `{"commit":"` + commit + `","verification":` + verifiedJSON + `}`, + commit: "", + }, + { + name: "malformed value", + env: `{"commit":`, + commit: commit, + want: invalid, + }, + { + name: "value without a verification", + env: `{"commit":"` + commit + `"}`, + commit: commit, + want: invalid, + }, + { + name: "unknown status", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"trusted"}}`, + commit: commit, + want: invalid, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + t.Setenv(ChainloopSandboxCommitVerificationEnvVar, tc.env) + + got := s.runner.VerifyCommitSignature(context.Background(), tc.commit) + s.Equal(tc.want, got) + }) + } +} + +func (s *chainloopSandboxSuite) TestSandboxCommitVerificationRoundTrip() { + const commit = "ea3a974660a030f0314aa1505a64792bf2633b33" + + want := &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnverified, + Reason: "Signature not verified: unverified_key", + Platform: "gitlab", + KeyID: "2", + SignatureAlgorithm: "PGP", + } + + raw, err := json.Marshal(SandboxCommitVerification{Commit: commit, Verification: want}) + s.Require().NoError(err) + s.T().Setenv(ChainloopSandboxCommitVerificationEnvVar, string(raw)) + + s.Equal(want, s.runner.VerifyCommitSignature(context.Background(), commit)) +} + func (s *chainloopSandboxSuite) SetupTest() { s.runner = NewChainloopSandbox() } diff --git a/pkg/attestation/crafter/runners/commitverification/commitverification.go b/pkg/attestation/crafter/runners/commitverification/commitverification.go index 773cf64f7..27948ca91 100644 --- a/pkg/attestation/crafter/runners/commitverification/commitverification.go +++ b/pkg/attestation/crafter/runners/commitverification/commitverification.go @@ -15,6 +15,8 @@ package commitverification +import "fmt" + // VerificationStatus represents the status of a commit signature verification type VerificationStatus int @@ -31,18 +33,60 @@ const ( VerificationStatusNotApplicable ) +// verificationStatusNames are the text forms of the statuses. They are the same +// as the status names in the attestation. +var verificationStatusNames = map[VerificationStatus]string{ + VerificationStatusUnspecified: "unspecified", + VerificationStatusVerified: "verified", + VerificationStatusUnverified: "unverified", + VerificationStatusUnavailable: "unavailable", + VerificationStatusNotApplicable: "not_applicable", +} + +// String returns the text form of the status. +func (s VerificationStatus) String() string { + if name, ok := verificationStatusNames[s]; ok { + return name + } + + return fmt.Sprintf("VerificationStatus(%d)", int(s)) +} + +// MarshalText encodes the status as its text form, so that JSON carries a name +// and not a number that depends on the order of the constants. +func (s VerificationStatus) MarshalText() ([]byte, error) { + name, ok := verificationStatusNames[s] + if !ok { + return nil, fmt.Errorf("unknown verification status %d", int(s)) + } + + return []byte(name), nil +} + +// UnmarshalText decodes the text form of a status. +func (s *VerificationStatus) UnmarshalText(text []byte) error { + for status, name := range verificationStatusNames { + if name == string(text) { + *s = status + return nil + } + } + + return fmt.Errorf("unknown verification status %q", string(text)) +} + // CommitVerification represents the result of a commit signature verification type CommitVerification struct { // Whether verification was attempted - Attempted bool + Attempted bool `json:"attempted"` // Verification status - Status VerificationStatus + Status VerificationStatus `json:"status"` // Human-readable reason for the status - Reason string + Reason string `json:"reason,omitempty"` // Platform that performed the verification (e.g., "github", "gitlab") - Platform string + Platform string `json:"platform,omitempty"` // Optional: The signing key ID if verified - KeyID string + KeyID string `json:"key_id,omitempty"` // Optional: The signature algorithm used - SignatureAlgorithm string + SignatureAlgorithm string `json:"signature_algorithm,omitempty"` } diff --git a/pkg/attestation/crafter/runners/commitverification/commitverification_test.go b/pkg/attestation/crafter/runners/commitverification/commitverification_test.go new file mode 100644 index 000000000..38b9f7a13 --- /dev/null +++ b/pkg/attestation/crafter/runners/commitverification/commitverification_test.go @@ -0,0 +1,79 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commitverification + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerificationStatusText(t *testing.T) { + testCases := []struct { + status VerificationStatus + text string + }{ + {VerificationStatusUnspecified, "unspecified"}, + {VerificationStatusVerified, "verified"}, + {VerificationStatusUnverified, "unverified"}, + {VerificationStatusUnavailable, "unavailable"}, + {VerificationStatusNotApplicable, "not_applicable"}, + } + + for _, tc := range testCases { + t.Run(tc.text, func(t *testing.T) { + got, err := tc.status.MarshalText() + require.NoError(t, err) + assert.Equal(t, tc.text, string(got)) + assert.Equal(t, tc.text, tc.status.String()) + + var parsed VerificationStatus + require.NoError(t, parsed.UnmarshalText([]byte(tc.text))) + assert.Equal(t, tc.status, parsed) + }) + } +} + +func TestVerificationStatusTextErrors(t *testing.T) { + _, err := VerificationStatus(99).MarshalText() + require.Error(t, err) + + var parsed VerificationStatus + require.Error(t, parsed.UnmarshalText([]byte("trusted"))) +} + +const signatureAlgorithmPGP = "PGP" + +func TestCommitVerificationJSON(t *testing.T) { + want := &CommitVerification{ + Attempted: true, + Status: VerificationStatusVerified, + Reason: "Commit signed and verified", + Platform: "gitlab", + KeyID: "1", + SignatureAlgorithm: signatureAlgorithmPGP, + } + + raw, err := json.Marshal(want) + require.NoError(t, err) + assert.JSONEq(t, `{"attempted":true,"status":"verified","reason":"Commit signed and verified","platform":"gitlab","key_id":"1","signature_algorithm":"PGP"}`, string(raw)) + + var got CommitVerification + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, want, &got) +} From 2ee9aaedfbfb677bec84614f06103ceebf1a8fb2 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 13 Oct 2026 14:44:18 +0200 Subject: [PATCH 2/2] fix(runners): record unavailable for a given verification without a status A given verification with no status, or with the unspecified status, carries no result. The runner now records the unavailable status for it, as it does for a value without a verification. Before, the attestation recorded the unspecified status. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 4503bdcc-d428-4e05-ade6-966360bf5543 --- pkg/attestation/crafter/runners/chainloopsandbox.go | 4 +++- .../crafter/runners/chainloopsandbox_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/attestation/crafter/runners/chainloopsandbox.go b/pkg/attestation/crafter/runners/chainloopsandbox.go index b1d14b73a..078d7d0ca 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox.go @@ -92,8 +92,10 @@ func (r *ChainloopSandbox) VerifyCommitSignature(_ context.Context, commitHash s return nil } + // A verification without a status carries no result, the same as a missing one. var given SandboxCommitVerification - if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil { + if err := json.Unmarshal([]byte(raw), &given); err != nil || given.Verification == nil || + given.Verification.Status == commitverification.VerificationStatusUnspecified { return &commitverification.CommitVerification{ Attempted: true, Status: commitverification.VerificationStatusUnavailable, diff --git a/pkg/attestation/crafter/runners/chainloopsandbox_test.go b/pkg/attestation/crafter/runners/chainloopsandbox_test.go index 1b6530a8a..572b33404 100644 --- a/pkg/attestation/crafter/runners/chainloopsandbox_test.go +++ b/pkg/attestation/crafter/runners/chainloopsandbox_test.go @@ -182,6 +182,18 @@ func (s *chainloopSandboxSuite) TestVerifyCommitSignature() { commit: commit, want: invalid, }, + { + name: "verification without a status", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"platform":"gitlab"}}`, + commit: commit, + want: invalid, + }, + { + name: "unspecified status", + env: `{"commit":"` + commit + `","verification":{"attempted":true,"status":"unspecified"}}`, + commit: commit, + want: invalid, + }, } for _, tc := range testCases {