Skip to content
Draft
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
45 changes: 43 additions & 2 deletions pkg/attestation/crafter/runners/chainloopsandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,30 @@ 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"
)

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we trust a json struct to decide wether something is verified or not? My understanding is that to show verified, we do rely on a signed JWT that we check?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In CI, the commit verification does not come from a signed JWT either. The CLI calls the GitHub or GitLab commit API with GITHUB_TOKEN or GITLAB_TOKEN and records the answer (VerifyGitHubCommit, VerifyGitLabCommit). The signed JWT that the CLI checks is the runner OIDC token. It sets runner.authenticated and the runner environment, not the commit verification.

Here the platform calls the same functions and gives the result to the CLI. The value is not signed, by decision D-009 in the spec (chainloop-dev/platform#6821). Any run with CHAINLOOP_SANDBOX can set it, and the sandbox runner records authenticated: false. The attestation content comes from the client: a token holder with a changed CLI can already write any git.head values. The spec lists a signed result as a later hardening: the platform signs the result and the CLI checks the signature.

If you want the signed result in this change, I will update the spec and do it in both PRs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure, I just want to make sure that whatever security contraints we have today to define that a commit is verified it's kept, so I am not 100% sure

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not keep it. Today, to record a forged "verified", you must change the CLI or build the attestation by hand. With this PR, two environment variables on the released CLI are enough.

To keep today's constraint, the CLI must accept only a result that it can authenticate. I will change the design: the platform signs the result, and the CLI verifies the signature with a key that it trusts. I will update the spec first (D-009 changes), then this PR and chainloop-dev/platform#6861. I am putting this PR back to draft until then.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new design is in the spec PR chainloop-dev/platform#6875, and it is wider than this result. The platform gives each sandbox run a signed identity, with a short-lived certificate from the control plane. The CLI and the control plane verify it. A verified run records an authenticated sandbox runner, and the commit verification travels in the identity. The control plane rejects an attestation that claims an authenticated sandbox runner, or a commit verification, that the identity does not support. This PR stays in draft until the spec merges.


// 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
}
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An explicitly empty value is treated as though the variable were unset, so malformed input does not produce the promised unavailable status. Use os.LookupEnv to distinguish an unset variable and let an empty value reach the decode-error path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At pkg/attestation/crafter/runners/chainloopsandbox.go, line 90:

<comment>An explicitly empty value is treated as though the variable were unset, so malformed input does not produce the promised `unavailable` status. Use `os.LookupEnv` to distinguish an unset variable and let an empty value reach the decode-error path.</comment>

<file context>
@@ -67,8 +83,31 @@ func (r *ChainloopSandbox) Environment() RunnerEnvironment {
+// 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
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed, by decision. An empty variable is the usual way to unset a variable, so the runner treats it as not set and records no verification. The platform never sends an empty value: it omits the variable when it has no result. Neither outcome records a verified commit.

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 {
Expand Down
135 changes: 135 additions & 0 deletions pkg/attestation/crafter/runners/chainloopsandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

package commitverification

import "fmt"

// VerificationStatus represents the status of a commit signature verification
type VerificationStatus int

Expand All @@ -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"`
}
Original file line number Diff line number Diff line change
@@ -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)
}
Loading