diff --git a/pkg/attestation/crafter/runners/chainloopsandbox.go b/pkg/attestation/crafter/runners/chainloopsandbox.go index 46435abc2..078d7d0ca 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,33 @@ 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 + } + + // 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 || + given.Verification.Status == commitverification.VerificationStatusUnspecified { + 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..572b33404 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,138 @@ 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, + }, + { + 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 { + 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) +}