From cc150b4bffaf7d436cc7d01df705229703997bdb Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 7 Oct 2026 17:36:07 +0200 Subject: [PATCH 1/3] fix(crafter): make GitLab commit verification reliable in Dagger runs - Discover runners in a fixed order with the Dagger runner first. The Chainloop Dagger module passes the parent GitLab CI context to the CLI container, so the Dagger and GitLab runners both matched and map iteration picked one at random. When the GitLab runner won, attestation init failed on missing GitLab variables. - Send GITLAB_TOKEN (read_api scope) as PRIVATE-TOKEN. The GitLab commit signature API does not accept job tokens. - Only a "404 Signature Not Found" response marks the commit as unsigned. "Project Not Found" and "Commit Not Found" now give an unavailable status with the GitLab message. - Report verified_system signatures (made by GitLab) as verified. - Take signature.algorithm from the commit signature when the platform does not report it. - Log the reason of an unavailable verification as a warning. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 38d0eb57-8573-4185-8a52-f49f140d0ea8 --- pkg/attestation/crafter/crafter.go | 9 +- pkg/attestation/crafter/runner.go | 34 ++- pkg/attestation/crafter/runner_test.go | 158 +++++++++++++ .../runners/commitverification/github.go | 6 +- .../runners/commitverification/gitlab.go | 84 ++++++- .../runners/commitverification/gitlab_test.go | 210 ++++++++++++++++++ .../crafter/runners/daggerpipeline.go | 10 +- .../crafter/runners/gitlabpipeline.go | 10 +- .../testdata/attestation.output-2.v0.2.json | 3 +- .../testdata/attestation.output-2.v2.json | 3 +- pkg/attestation/renderer/chainloop/v02.go | 14 +- .../renderer/chainloop/v02_test.go | 69 ++++++ 12 files changed, 580 insertions(+), 30 deletions(-) create mode 100644 pkg/attestation/crafter/runner_test.go create mode 100644 pkg/attestation/crafter/runners/commitverification/gitlab_test.go diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 893ddf899..b69a6d948 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -461,7 +461,7 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error if headCommit != nil { // Attempt platform verification if opts.Runner != nil { - headCommit.PlatformVerification = verifyCommitWithPlatform(headCommit, opts.Runner) + headCommit.PlatformVerification = verifyCommitWithPlatform(headCommit, opts.Runner, opts.Logger) } headCommitP = &api.Commit{ @@ -1189,7 +1189,7 @@ func (c *Crafter) requireStateLoaded() error { // verifyCommitWithPlatform attempts to verify commit signature using platform APIs // Returns nil if verification is not available or not applicable -func verifyCommitWithPlatform(commit *HeadCommit, runner SupportedRunner) *api.Commit_CommitVerification { +func verifyCommitWithPlatform(commit *HeadCommit, runner SupportedRunner, logger *zerolog.Logger) *api.Commit_CommitVerification { // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -1200,6 +1200,11 @@ func verifyCommitWithPlatform(commit *HeadCommit, runner SupportedRunner) *api.C return nil } + // The reason is not part of the attestation, so show it to explain the unavailable status + if verification.Status == commitverification.VerificationStatusUnavailable && logger != nil { + logger.Warn().Str("platform", verification.Platform).Str("reason", verification.Reason).Msg("commit signature verification unavailable") + } + // Convert from commitverification type to protobuf type return convertCommitVerification(verification) } diff --git a/pkg/attestation/crafter/runner.go b/pkg/attestation/crafter/runner.go index 9c34995ef..f55957dae 100644 --- a/pkg/attestation/crafter/runner.go +++ b/pkg/attestation/crafter/runner.go @@ -16,9 +16,11 @@ package crafter import ( + "cmp" "context" "errors" "fmt" + "slices" "time" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" @@ -117,12 +119,12 @@ func NewRunner(t schemaapi.CraftingSchema_Runner_RunnerType, authToken string, l // DiscoverRunner the runner environment // This method does a simple check to see which runner is available in the environment -// by iterating over the different runners and performing duck-typing checks. +// by iterating over the different runners in discovery order and performing duck-typing checks. // It returns the first matching runner immediately to avoid unnecessary CheckEnv() // calls from remaining runners. func DiscoverRunner(authToken string, logger zerolog.Logger) SupportedRunner { - for _, factory := range RunnerFactories { - r := factory(authToken, &logger) + for _, runnerType := range runnerDiscoveryOrder() { + r := RunnerFactories[runnerType](authToken, &logger) if r.CheckEnv() { return r } @@ -131,6 +133,32 @@ func DiscoverRunner(authToken string, logger zerolog.Logger) SupportedRunner { return runners.NewGeneric() } +// runnerDiscoveryOrder returns the runner types in the order DiscoverRunner checks them. +// The order must not depend on map iteration, because more than one runner can match the +// same environment. The Dagger runner goes first: the Chainloop Dagger module also passes +// the parent CI context (for example GITLAB_CI and CI_JOB_URL) to the CLI container, so +// the parent CI runner matches too. The other runners follow in enum order. +func runnerDiscoveryOrder() []schemaapi.CraftingSchema_Runner_RunnerType { + order := make([]schemaapi.CraftingSchema_Runner_RunnerType, 0, len(RunnerFactories)) + for runnerType := range RunnerFactories { + order = append(order, runnerType) + } + + slices.SortFunc(order, func(a, b schemaapi.CraftingSchema_Runner_RunnerType) int { + const first = schemaapi.CraftingSchema_Runner_DAGGER_PIPELINE + switch { + case a == first: + return -1 + case b == first: + return 1 + default: + return cmp.Compare(a, b) + } + }) + + return order +} + func DiscoverAndEnforceRunner(enforcedRunnerType schemaapi.CraftingSchema_Runner_RunnerType, dryRun bool, authToken string, logger zerolog.Logger) (SupportedRunner, error) { discoveredRunner := DiscoverRunner(authToken, logger) diff --git a/pkg/attestation/crafter/runner_test.go b/pkg/attestation/crafter/runner_test.go new file mode 100644 index 000000000..de312a7ae --- /dev/null +++ b/pkg/attestation/crafter/runner_test.go @@ -0,0 +1,158 @@ +// +// 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 crafter + +import ( + "bytes" + "context" + "testing" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Environments that make the CheckEnv of a runner match +var ( + gitlabEnv = map[string]string{"GITLAB_CI": "true", "CI_JOB_URL": "https://gitlab.example.com/group/project/-/jobs/1"} + githubEnv = map[string]string{"CI": "true", "GITHUB_REPOSITORY": "owner/repo", "GITHUB_RUN_ID": "1"} + daggerEnv = map[string]string{"CHAINLOOP_DAGGER_CLIENT": "v1.0.0"} + // otherRunnerEnvVars are read by the CheckEnv of the remaining runners + otherRunnerEnvVars = []string{"TF_BUILD", "BUILD_BUILDURI", "JENKINS_HOME", "BUILD_URL", "CIRCLECI", "TEAMCITY_PROJECT_NAME", "CHAINLOOP_SANDBOX"} +) + +func TestDiscoverRunner(t *testing.T) { + testCases := []struct { + name string + envs []map[string]string + want schemaapi.CraftingSchema_Runner_RunnerType + }{ + { + name: "no runner environment", + want: schemaapi.CraftingSchema_Runner_RUNNER_TYPE_UNSPECIFIED, + }, + { + name: "gitlab pipeline", + envs: []map[string]string{gitlabEnv}, + want: schemaapi.CraftingSchema_Runner_GITLAB_PIPELINE, + }, + { + name: "github action", + envs: []map[string]string{githubEnv}, + want: schemaapi.CraftingSchema_Runner_GITHUB_ACTION, + }, + { + // The Chainloop Dagger module passes the parent GitLab CI context to the CLI container + name: "dagger pipeline with gitlab context", + envs: []map[string]string{daggerEnv, gitlabEnv}, + want: schemaapi.CraftingSchema_Runner_DAGGER_PIPELINE, + }, + { + name: "dagger pipeline with github context", + envs: []map[string]string{daggerEnv, githubEnv}, + want: schemaapi.CraftingSchema_Runner_DAGGER_PIPELINE, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Clear the runner variables of the environment the test runs in, for example a CI job + for _, env := range []map[string]string{gitlabEnv, githubEnv, daggerEnv} { + for name := range env { + t.Setenv(name, "") + } + } + for _, name := range otherRunnerEnvVars { + t.Setenv(name, "") + } + for _, env := range tc.envs { + for name, value := range env { + t.Setenv(name, value) + } + } + + // Discover several times: map iteration order is random, so a + // single call can pick the expected runner by chance + for range 50 { + assert.Equal(t, tc.want, DiscoverRunner("", zerolog.Nop()).ID()) + } + }) + } +} + +// verifyingRunner is a runner that returns a fixed commit verification result +type verifyingRunner struct { + *runners.Generic + verification *commitverification.CommitVerification +} + +func (r *verifyingRunner) VerifyCommitSignature(_ context.Context, _ string) *commitverification.CommitVerification { + return r.verification +} + +func TestVerifyCommitWithPlatformLogsUnavailableReason(t *testing.T) { + testCases := []struct { + name string + verification *commitverification.CommitVerification + wantStatus api.Commit_CommitVerification_VerificationStatus + wantWarning bool + }{ + { + name: "unavailable", + verification: &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusUnavailable, + Reason: "GitLab API error: x509: certificate signed by unknown authority", + Platform: "gitlab", + }, + wantStatus: api.Commit_CommitVerification_unavailable, + wantWarning: true, + }, + { + name: "verified", + verification: &commitverification.CommitVerification{ + Attempted: true, + Status: commitverification.VerificationStatusVerified, + Reason: "Commit signed and verified", + Platform: "gitlab", + }, + wantStatus: api.Commit_CommitVerification_verified, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var logs bytes.Buffer + logger := zerolog.New(&logs) + runner := &verifyingRunner{Generic: runners.NewGeneric(), verification: tc.verification} + + got := verifyCommitWithPlatform(&HeadCommit{Hash: "abc"}, runner, &logger) + + require.NotNil(t, got) + assert.Equal(t, tc.wantStatus, got.GetStatus()) + if tc.wantWarning { + assert.Contains(t, logs.String(), `"level":"warn"`) + assert.Contains(t, logs.String(), tc.verification.Reason) + } else { + assert.Empty(t, logs.String()) + } + }) + } +} diff --git a/pkg/attestation/crafter/runners/commitverification/github.go b/pkg/attestation/crafter/runners/commitverification/github.go index f3068a7f2..4db5f2857 100644 --- a/pkg/attestation/crafter/runners/commitverification/github.go +++ b/pkg/attestation/crafter/runners/commitverification/github.go @@ -127,7 +127,7 @@ func VerifyGitHubCommit(ctx context.Context, owner, repo, commitHash, token stri } // Detect signature type from the signature content - signatureAlgorithm := detectSignatureType(verification.Signature) + signatureAlgorithm := DetectSignatureType(verification.Signature) if logger != nil { logger.Debug().Int("status", int(status)).Str("reason", verification.Reason).Bool("verified", verification.Verified).Str("signature_type", signatureAlgorithm).Msg("GitHub commit verification completed") @@ -142,12 +142,12 @@ func VerifyGitHubCommit(ctx context.Context, owner, repo, commitHash, token stri } } -// detectSignatureType inspects the signature content to determine its type +// DetectSignatureType inspects the signature content to determine its type // GitHub supports GPG, SSH, and S/MIME signatures // Format references: // - Git documentation: https://git-scm.com/docs/gitformat-signature // - SSH format: https://blog.gitbutler.com/signing-commits-in-git-explained -func detectSignatureType(signature string) string { +func DetectSignatureType(signature string) string { if signature == "" { return "" } diff --git a/pkg/attestation/crafter/runners/commitverification/gitlab.go b/pkg/attestation/crafter/runners/commitverification/gitlab.go index bbb0b6b08..6c7f343ce 100644 --- a/pkg/attestation/crafter/runners/commitverification/gitlab.go +++ b/pkg/attestation/crafter/runners/commitverification/gitlab.go @@ -19,15 +19,48 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" + "os" + "strings" "time" "github.com/rs/zerolog" ) +const ( + // gitlabStatusVerified is the status of a signature made with a key of the commit author + gitlabStatusVerified = "verified" + // gitlabStatusVerifiedSystem is the status of a signature made by GitLab itself, for + // example for commits created in the web UI or when merging a merge request + gitlabStatusVerifiedSystem = "verified_system" + // gitlabSignatureNotFound is the error message GitLab returns for an unsigned commit + gitlabSignatureNotFound = "Signature Not Found" + // maxGitLabErrorBodySize limits how much of an error response body is read + maxGitLabErrorBodySize = 64 * 1024 +) + +// GitLabCredentials holds the tokens that can authenticate calls to the GitLab API +type GitLabCredentials struct { + // APIToken is a personal, project or group access token with the read_api scope. + // It is required to verify commits of private and internal projects. + APIToken string + // JobToken is the CI/CD job token. GitLab does not accept job tokens on the commit + // signature endpoint and handles the request as anonymous, which only works for public projects. + JobToken string +} + +// GitLabCredentialsFromEnv reads the GitLab API credentials from GITLAB_TOKEN and CI_JOB_TOKEN +func GitLabCredentialsFromEnv() GitLabCredentials { + return GitLabCredentials{ + APIToken: os.Getenv("GITLAB_TOKEN"), + JobToken: os.Getenv("CI_JOB_TOKEN"), + } +} + // VerifyGitLabCommit verifies a commit signature using the GitLab API -func VerifyGitLabCommit(ctx context.Context, baseURL, projectPath, commitHash, token string, logger *zerolog.Logger) *CommitVerification { +func VerifyGitLabCommit(ctx context.Context, baseURL, projectPath, commitHash string, credentials GitLabCredentials, logger *zerolog.Logger) *CommitVerification { // URL encode the project path (e.g., "group/project" -> "group%2Fproject") encodedProject := url.PathEscape(projectPath) @@ -52,8 +85,11 @@ func VerifyGitLabCommit(ctx context.Context, baseURL, projectPath, commitHash, t } // Set headers - if token != "" { - req.Header.Set("JOB-TOKEN", token) + switch { + case credentials.APIToken != "": + req.Header.Set("PRIVATE-TOKEN", credentials.APIToken) + case credentials.JobToken != "": + req.Header.Set("JOB-TOKEN", credentials.JobToken) } // Make request @@ -77,12 +113,30 @@ func VerifyGitLabCommit(ctx context.Context, baseURL, projectPath, commitHash, t logger.Debug().Int("status", resp.StatusCode).Str("commit", commitHash).Msg("GitLab API returned non-OK status") } - // 404 means the commit is unsigned (no signature data available) + // GitLab also answers 404 when the project or the commit is not visible to the + // caller, so only the "Signature Not Found" message means the commit is unsigned if resp.StatusCode == http.StatusNotFound { + message := gitlabErrorMessage(resp.Body) + if strings.Contains(message, gitlabSignatureNotFound) { + return &CommitVerification{ + Attempted: true, + Status: VerificationStatusNotApplicable, + Reason: "Commit is not signed", + Platform: "gitlab", + } + } + + if message == "" { + message = "HTTP 404" + } + reason := fmt.Sprintf("GitLab API error: %s", message) + if credentials.APIToken == "" { + reason += " (set GITLAB_TOKEN to a token with the read_api scope to verify commits of private and internal projects)" + } return &CommitVerification{ Attempted: true, - Status: VerificationStatusNotApplicable, - Reason: "Commit is not signed", + Status: VerificationStatusUnavailable, + Reason: reason, Platform: "gitlab", } } @@ -121,9 +175,12 @@ func VerifyGitLabCommit(ctx context.Context, baseURL, projectPath, commitHash, t var keyID string var signatureAlgorithm string - if signatureResponse.VerificationStatus == "verified" { + if signatureResponse.VerificationStatus == gitlabStatusVerified || signatureResponse.VerificationStatus == gitlabStatusVerifiedSystem { status = VerificationStatusVerified reason = "Commit signed and verified" + if signatureResponse.VerificationStatus == gitlabStatusVerifiedSystem { + reason = "Commit signed by GitLab and verified" + } if signatureResponse.GPGKeyID != 0 { keyID = fmt.Sprintf("%d", signatureResponse.GPGKeyID) } else if signatureResponse.GPGKeyPrimaryKeyID != "" { @@ -153,6 +210,19 @@ func VerifyGitLabCommit(ctx context.Context, baseURL, projectPath, commitHash, t } } +// gitlabErrorMessage returns the message of a GitLab API error response, or an empty +// string when the body is not a GitLab error +func gitlabErrorMessage(body io.Reader) string { + var errorResponse struct { + Message string `json:"message"` + } + if err := json.NewDecoder(io.LimitReader(body, maxGitLabErrorBodySize)).Decode(&errorResponse); err != nil { + return "" + } + + return errorResponse.Message +} + // gitlabCommitResponse represents the GitLab API response for commit signature // from the /signature endpoint (not the general commits endpoint) type gitlabCommitResponse struct { diff --git a/pkg/attestation/crafter/runners/commitverification/gitlab_test.go b/pkg/attestation/crafter/runners/commitverification/gitlab_test.go new file mode 100644 index 000000000..ad7b310b0 --- /dev/null +++ b/pkg/attestation/crafter/runners/commitverification/gitlab_test.go @@ -0,0 +1,210 @@ +// +// 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 ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testCommitHash = "ea3a974660a030f0314aa1505a64792bf2633b33" + testAPIToken = "api-token" + testJobToken = "job-token" +) + +// Response bodies as returned by the GitLab API +const ( + gitlabSSHVerifiedSystemBody = `{"signature_type":"SSH","verification_status":"verified_system","key":{"id":14178941,"title":"Gitlab Signing","usage_type":"signing"},"commit_source":"gitaly"}` + gitlabPGPVerifiedBody = `{"signature_type":"PGP","verification_status":"verified","gpg_key_id":1,"gpg_key_primary_keyid":"8254AAB3FBD54AC9","gpg_key_user_name":"John Doe","gpg_key_user_email":"johndoe@example.com","gpg_key_subkey_id":null,"commit_source":"gitaly"}` + gitlabPGPUnverifiedKeyBody = `{"signature_type":"PGP","verification_status":"unverified_key","gpg_key_id":2,"commit_source":"gitaly"}` +) + +func TestVerifyGitLabCommit(t *testing.T) { + testCases := []struct { + name string + statusCode int + body string + credentials GitLabCredentials + wantStatus VerificationStatus + wantAlgorithm string + wantKeyID string + wantReason []string + }{ + { + name: "pgp signature verified", + statusCode: http.StatusOK, + body: gitlabPGPVerifiedBody, + wantStatus: VerificationStatusVerified, + wantAlgorithm: "PGP", + wantKeyID: "1", + }, + { + name: "ssh signature made by GitLab is verified", + statusCode: http.StatusOK, + body: gitlabSSHVerifiedSystemBody, + wantStatus: VerificationStatusVerified, + wantAlgorithm: "SSH", + }, + { + name: "pgp signature with an unverified key", + statusCode: http.StatusOK, + body: gitlabPGPUnverifiedKeyBody, + wantStatus: VerificationStatusUnverified, + wantAlgorithm: "PGP", + wantKeyID: "2", + wantReason: []string{"unverified_key"}, + }, + { + name: "commit is not signed", + statusCode: http.StatusNotFound, + body: `{"message":"404 Signature Not Found"}`, + wantStatus: VerificationStatusNotApplicable, + }, + { + // GitLab answers 404 to anonymous requests for private and internal projects + name: "project not visible without an API token", + statusCode: http.StatusNotFound, + body: `{"message":"404 Project Not Found"}`, + wantStatus: VerificationStatusUnavailable, + wantReason: []string{"404 Project Not Found", "GITLAB_TOKEN"}, + }, + { + name: "project not visible with an API token", + statusCode: http.StatusNotFound, + body: `{"message":"404 Project Not Found"}`, + credentials: GitLabCredentials{APIToken: testAPIToken}, + wantStatus: VerificationStatusUnavailable, + wantReason: []string{"404 Project Not Found"}, + }, + { + name: "commit not known to GitLab", + statusCode: http.StatusNotFound, + body: `{"message":"404 Commit Not Found"}`, + wantStatus: VerificationStatusUnavailable, + wantReason: []string{"404 Commit Not Found"}, + }, + { + name: "invalid API token", + statusCode: http.StatusUnauthorized, + body: `{"message":"401 Unauthorized"}`, + credentials: GitLabCredentials{APIToken: testAPIToken}, + wantStatus: VerificationStatusUnavailable, + wantReason: []string{"authentication failed"}, + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + body: `{"message":"500 Internal Server Error"}`, + wantStatus: VerificationStatusUnavailable, + wantReason: []string{"HTTP 500"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, fmt.Sprintf("/api/v4/projects/group%%2Fproject/repository/commits/%s/signature", testCommitHash), r.URL.RawPath) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.statusCode) + fmt.Fprint(w, tc.body) + })) + defer server.Close() + + got := VerifyGitLabCommit(context.Background(), server.URL, "group/project", testCommitHash, tc.credentials, nil) + require.NotNil(t, got) + + assert.True(t, got.Attempted) + assert.Equal(t, "gitlab", got.Platform) + assert.Equal(t, tc.wantStatus, got.Status) + assert.Equal(t, tc.wantAlgorithm, got.SignatureAlgorithm) + assert.Equal(t, tc.wantKeyID, got.KeyID) + for _, want := range tc.wantReason { + assert.Contains(t, got.Reason, want) + } + }) + } +} + +func TestVerifyGitLabCommitAuthHeaders(t *testing.T) { + testCases := []struct { + name string + credentials GitLabCredentials + wantPrivateToken string + wantJobToken string + }{ + { + name: "no credentials", + }, + { + name: "api token", + credentials: GitLabCredentials{APIToken: testAPIToken}, + wantPrivateToken: testAPIToken, + }, + { + name: "job token", + credentials: GitLabCredentials{JobToken: testJobToken}, + wantJobToken: testJobToken, + }, + { + // GitLab does not accept job tokens on the signature endpoint, so the API token wins + name: "api token and job token", + credentials: GitLabCredentials{APIToken: testAPIToken, JobToken: testJobToken}, + wantPrivateToken: testAPIToken, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.wantPrivateToken, r.Header.Get("PRIVATE-TOKEN")) + assert.Equal(t, tc.wantJobToken, r.Header.Get("JOB-TOKEN")) + fmt.Fprint(w, gitlabPGPVerifiedBody) + })) + defer server.Close() + + got := VerifyGitLabCommit(context.Background(), server.URL, "group/project", testCommitHash, tc.credentials, nil) + assert.Equal(t, VerificationStatusVerified, got.Status) + }) + } +} + +func TestVerifyGitLabCommitUntrustedCertificate(t *testing.T) { + // The server certificate is not signed by a CA in the system pool, like a + // self-managed GitLab instance that uses a private CA + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, gitlabPGPVerifiedBody) + })) + defer server.Close() + + got := VerifyGitLabCommit(context.Background(), server.URL, "group/project", testCommitHash, GitLabCredentials{}, nil) + + assert.Equal(t, VerificationStatusUnavailable, got.Status) + assert.Contains(t, got.Reason, "certificate") +} + +func TestGitLabCredentialsFromEnv(t *testing.T) { + t.Setenv("GITLAB_TOKEN", testAPIToken) + t.Setenv("CI_JOB_TOKEN", testJobToken) + + assert.Equal(t, GitLabCredentials{APIToken: testAPIToken, JobToken: testJobToken}, GitLabCredentialsFromEnv()) +} diff --git a/pkg/attestation/crafter/runners/daggerpipeline.go b/pkg/attestation/crafter/runners/daggerpipeline.go index 7dd21b029..6c8eaa200 100644 --- a/pkg/attestation/crafter/runners/daggerpipeline.go +++ b/pkg/attestation/crafter/runners/daggerpipeline.go @@ -176,14 +176,14 @@ func (r *DaggerPipeline) verifyCommitViaGitLab(ctx context.Context, commitHash s return nil } - // Get CI_JOB_TOKEN for API access - token := os.Getenv("CI_JOB_TOKEN") - if token == "" && r.logger != nil { - r.logger.Debug().Msg("CI_JOB_TOKEN not set, using unauthenticated requests") + // GITLAB_TOKEN is needed for private and internal projects, the job token is not accepted + credentials := commitverification.GitLabCredentialsFromEnv() + if credentials.APIToken == "" && r.logger != nil { + r.logger.Debug().Msg("GITLAB_TOKEN not set, only commits of public projects can be verified") } // Call GitLab API to verify commit - return commitverification.VerifyGitLabCommit(ctx, baseURL, projectPath, commitHash, token, r.logger) + return commitverification.VerifyGitLabCommit(ctx, baseURL, projectPath, commitHash, credentials, r.logger) } func (r *DaggerPipeline) Report(_ []byte, _ string) error { diff --git a/pkg/attestation/crafter/runners/gitlabpipeline.go b/pkg/attestation/crafter/runners/gitlabpipeline.go index 5fc84baba..88903c0a8 100644 --- a/pkg/attestation/crafter/runners/gitlabpipeline.go +++ b/pkg/attestation/crafter/runners/gitlabpipeline.go @@ -142,14 +142,14 @@ func (r *GitlabPipeline) VerifyCommitSignature(ctx context.Context, commitHash s return nil } - // Get CI_JOB_TOKEN for API access - token := os.Getenv("CI_JOB_TOKEN") - if token == "" { - r.logger.Debug().Msg("CI_JOB_TOKEN not set, using unauthenticated requests") + // GITLAB_TOKEN is needed for private and internal projects, the job token is not accepted + credentials := commitverification.GitLabCredentialsFromEnv() + if credentials.APIToken == "" { + r.logger.Debug().Msg("GITLAB_TOKEN not set, only commits of public projects can be verified") } // Call GitLab API to verify commit - return commitverification.VerifyGitLabCommit(ctx, baseURL, projectPath, commitHash, token, r.logger) + return commitverification.VerifyGitLabCommit(ctx, baseURL, projectPath, commitHash, credentials, r.logger) } // Report writes attestation table output as text artifact diff --git a/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v0.2.json b/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v0.2.json index 9cf0762ad..8b977bab4 100644 --- a/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v0.2.json +++ b/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v0.2.json @@ -23,7 +23,8 @@ "url": "git@cyberdyne.internal:skynet.git" } ], - "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJnPO+fCRC1aQ7uu5UhlAAAcwMQAFAxzNhnJwCso9KCMD+47FFY\n-----END PGP SIGNATURE-----\n\n" + "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJnPO+fCRC1aQ7uu5UhlAAAcwMQAFAxzNhnJwCso9KCMD+47FFY\n-----END PGP SIGNATURE-----\n\n", + "signature.algorithm": "PGP" } }, { diff --git a/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v2.json b/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v2.json index 9cf0762ad..8b977bab4 100644 --- a/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v2.json +++ b/pkg/attestation/renderer/chainloop/testdata/attestation.output-2.v2.json @@ -23,7 +23,8 @@ "url": "git@cyberdyne.internal:skynet.git" } ], - "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJnPO+fCRC1aQ7uu5UhlAAAcwMQAFAxzNhnJwCso9KCMD+47FFY\n-----END PGP SIGNATURE-----\n\n" + "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJnPO+fCRC1aQ7uu5UhlAAAcwMQAFAxzNhnJwCso9KCMD+47FFY\n-----END PGP SIGNATURE-----\n\n", + "signature.algorithm": "PGP" } }, { diff --git a/pkg/attestation/renderer/chainloop/v02.go b/pkg/attestation/renderer/chainloop/v02.go index e3574ffec..aa68a2862 100644 --- a/pkg/attestation/renderer/chainloop/v02.go +++ b/pkg/attestation/renderer/chainloop/v02.go @@ -28,6 +28,7 @@ import ( pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" crv1 "github.com/google/go-containerregistry/pkg/v1" intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" @@ -204,9 +205,16 @@ func commitAnnotations(c *v1.Commit) (*structpb.Struct, error) { // add verification only if exists as well as its fields if pv := c.GetPlatformVerification(); pv != nil { annotationsRaw[subjectGitAnnotationAuthorVerificationStatus] = pv.GetStatus().String() - if sig := pv.GetSignatureAlgorithm(); sig != "" { - annotationsRaw[subjectGitAnnotationSignatureAlgorithm] = sig - } + } + + // prefer the algorithm reported by the platform, and fall back to the commit signature + // when the platform could not be reached or does not verify commits + algorithm := c.GetPlatformVerification().GetSignatureAlgorithm() + if algorithm == "" { + algorithm = commitverification.DetectSignatureType(c.GetSignature()) + } + if algorithm != "" { + annotationsRaw[subjectGitAnnotationSignatureAlgorithm] = algorithm } if remotes := c.GetRemotes(); len(remotes) > 0 { diff --git a/pkg/attestation/renderer/chainloop/v02_test.go b/pkg/attestation/renderer/chainloop/v02_test.go index e74ffc207..0778933f5 100644 --- a/pkg/attestation/renderer/chainloop/v02_test.go +++ b/pkg/attestation/renderer/chainloop/v02_test.go @@ -928,3 +928,72 @@ func TestPolicyEvaluationsField(t *testing.T) { assert.Equal(t, "sbom", ev.MaterialName) assert.NotNil(t, ev.PolicyReference) } + +func TestCommitAnnotationsSignature(t *testing.T) { + const ( + pgpSignature = "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJnPO+f\n-----END PGP SIGNATURE-----\n" + sshSignature = "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQ==\n-----END SSH SIGNATURE-----\n" + sshAlgorithm = "SSH" + ) + + testCases := []struct { + name string + commit *api.Commit + wantStatus string + wantSignature string + wantAlgorithm string + }{ + { + name: "algorithm reported by the platform", + commit: &api.Commit{ + Signature: sshSignature, + PlatformVerification: &api.Commit_CommitVerification{ + Status: api.Commit_CommitVerification_verified, + SignatureAlgorithm: sshAlgorithm, + }, + }, + wantStatus: "verified", + wantSignature: sshSignature, + wantAlgorithm: sshAlgorithm, + }, + { + name: "platform verification unavailable falls back to the commit signature", + commit: &api.Commit{ + Signature: pgpSignature, + PlatformVerification: &api.Commit_CommitVerification{ + Status: api.Commit_CommitVerification_unavailable, + }, + }, + wantStatus: "unavailable", + wantSignature: pgpSignature, + wantAlgorithm: "PGP", + }, + { + name: "no platform verification falls back to the commit signature", + commit: &api.Commit{Signature: sshSignature}, + wantSignature: sshSignature, + wantAlgorithm: sshAlgorithm, + }, + { + name: "unsigned commit", + commit: &api.Commit{ + PlatformVerification: &api.Commit_CommitVerification{ + Status: api.Commit_CommitVerification_not_applicable, + }, + }, + wantStatus: "not_applicable", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + annotations, err := commitAnnotations(tc.commit) + require.NoError(t, err) + + fields := annotations.GetFields() + assert.Equal(t, tc.wantStatus, fields[subjectGitAnnotationAuthorVerificationStatus].GetStringValue()) + assert.Equal(t, tc.wantSignature, fields[subjectGitAnnotationSignature].GetStringValue()) + assert.Equal(t, tc.wantAlgorithm, fields[subjectGitAnnotationSignatureAlgorithm].GetStringValue()) + }) + } +} From 6aae058c014973303df46aa65303ba72c2f001da Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 7 Oct 2026 17:36:08 +0200 Subject: [PATCH 2/3] feat(dagger): add Gitlab token and CA options for commit verification Add --gitlab-token, passed to the CLI as GITLAB_TOKEN, to verify commits of private and internal projects, and --gitlab-ca to trust the CA of a self-managed Gitlab server through SSL_CERT_DIR. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 38d0eb57-8573-4185-8a52-f49f140d0ea8 --- extras/dagger/README.md | 21 +++++++++++++++++++++ extras/dagger/main.go | 27 +++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/extras/dagger/README.md b/extras/dagger/README.md index cf6bbd0ff..1c622b156 100644 --- a/extras/dagger/README.md +++ b/extras/dagger/README.md @@ -306,6 +306,27 @@ dagger call -m github.com/chainloop-dev/chainloop \ --key env:SIGNING_KEY ``` +### Commit signature verification (from GitLab CI) + +To verify the signature of the attested commit with GitLab, also pass the GitLab CI context. The GitLab commit signature API does not accept the job token, so for private and internal projects pass a personal, project or group access token with the `read_api` scope in `--gitlab-token`. + +If your self-managed GitLab server uses a certificate from a private CA, also pass that CA certificate, for example `--gitlab-ca=$CI_SERVER_TLS_CA_FILE` when the GitLab Runner is configured with it. + +```sh +dagger call -m github.com/chainloop-dev/chainloop \ + init \ + --token env:CHAINLOOP_TOKEN \ + --workflow-name build \ + --project-name my-project \ + --gitlab-ci=$GITLAB_CI \ + --gitlab-server-url=$CI_SERVER_URL \ + --gitlab-project-path=$CI_PROJECT_PATH \ + --gitlab-job-url=$CI_JOB_URL \ + --gitlab-job-token=env:CI_JOB_TOKEN \ + --gitlab-token=env:GITLAB_TOKEN \ + push +``` + ### Notes - All PR/MR parameters are optional - if not provided, attestations work normally without PR metadata diff --git a/extras/dagger/main.go b/extras/dagger/main.go index 5a7ec27fd..4f4c609dd 100644 --- a/extras/dagger/main.go +++ b/extras/dagger/main.go @@ -12,6 +12,9 @@ import ( const ( chainloopVersion = "v1.118.0" platformVersion = "v1.124.2" + // gitlabCADir holds the custom CA of the Gitlab server. The CLI image is scratch-based, + // so SSL_CERT_DIR points Go at it, on top of the default ca-certificates.crt bundle + gitlabCADir = "/etc/ssl/gitlab" ) var execOpts = dagger.ContainerWithExecOpts{ @@ -128,8 +131,12 @@ type ParentCIContext struct { GitlabMRProjectURL string // User login GitlabUserLogin string - // Gitlab job token for API access and commit verification + // Gitlab job token for API access, for example to read merge request details GitlabJobToken *dagger.Secret + // Gitlab token with the read_api scope for commit verification + GitlabToken *dagger.Secret + // Custom CA certificate of the Gitlab server + GitlabCA *dagger.File } // Initialize a new attestation @@ -224,9 +231,16 @@ func (m *Chainloop) Init( // Gitlab user login // +optional gitlabUserLogin string, - // Gitlab job token for API access and commit verification (when running in Gitlab CI) + // Gitlab job token for API access, for example to read merge request details (when running in Gitlab CI) // +optional gitlabJobToken *dagger.Secret, + // Gitlab personal, project or group access token with the read_api scope to verify the commit signature. + // Required for private and internal projects: the commit signature API does not accept job tokens + // +optional + gitlabToken *dagger.Secret, + // Custom CA certificate (PEM) of a self-managed Gitlab server that uses a private CA + // +optional + gitlabCA *dagger.File, ) (*Attestation, error) { // Construct ParentCIContext from individual parameters var parentCIContext *ParentCIContext @@ -255,6 +269,8 @@ func (m *Chainloop) Init( GitlabMRProjectURL: gitlabMRProjectURL, GitlabUserLogin: gitlabUserLogin, GitlabJobToken: gitlabJobToken, + GitlabToken: gitlabToken, + GitlabCA: gitlabCA, } } @@ -655,6 +671,13 @@ func cliContainer(ttl int, token *dagger.Secret, instance InstanceInfo, parentCI if parentCI.GitlabJobToken != nil { ctr = ctr.WithSecretVariable("CI_JOB_TOKEN", parentCI.GitlabJobToken) } + if parentCI.GitlabToken != nil { + ctr = ctr.WithSecretVariable("GITLAB_TOKEN", parentCI.GitlabToken) + } + if parentCI.GitlabCA != nil { + ctr = ctr.WithFile(gitlabCADir+"/ca.pem", parentCI.GitlabCA). + WithEnvVariable("SSL_CERT_DIR", gitlabCADir) + } } if token != nil { From 260c3adc79e8501aafad1a88dad28c92fb4b547f Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 7 Oct 2026 17:36:08 +0200 Subject: [PATCH 3/3] fix(cli): add a writable /tmp to the CLI container image The CLI image is scratch-based and had no /tmp, so collectors that write temporary files, such as the PR/MR metadata collector, failed. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 38d0eb57-8573-4185-8a52-f49f140d0ea8 --- app/cli/Dockerfile.goreleaser | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/cli/Dockerfile.goreleaser b/app/cli/Dockerfile.goreleaser index 874f7af53..0dab779af 100644 --- a/app/cli/Dockerfile.goreleaser +++ b/app/cli/Dockerfile.goreleaser @@ -1,11 +1,13 @@ FROM golang:1.27.1@sha256:1e93e00a31255c07e9a34c4207f3006e1501730c5323697cee7dfb827fdae44c AS builder -RUN mkdir -p /.config/chainloop +RUN mkdir -p /.config/chainloop /rootfs/tmp && chmod 1777 /rootfs/tmp FROM scratch COPY ./chainloop / COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder --chown=1001:1001 /.config/chainloop /.config/chainloop +# tmp is required for the temporary files the CLI writes, for example the PR/MR metadata +COPY --from=builder /rootfs/ / USER 1001