Skip to content

Commit 351f741

Browse files
authored
fix: reliable GitLab commit verification when the CLI runs through Dagger (#3550)
1 parent eae5f15 commit 351f741

15 files changed

Lines changed: 629 additions & 33 deletions

File tree

‎app/cli/Dockerfile.goreleaser‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
FROM golang:1.27.1@sha256:1e93e00a31255c07e9a34c4207f3006e1501730c5323697cee7dfb827fdae44c AS builder
2-
RUN mkdir -p /.config/chainloop
2+
RUN mkdir -p /.config/chainloop /rootfs/tmp && chmod 1777 /rootfs/tmp
33

44
FROM scratch
55

66
COPY ./chainloop /
77
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
88
COPY --from=builder --chown=1001:1001 /.config/chainloop /.config/chainloop
9+
# tmp is required for the temporary files the CLI writes, for example the PR/MR metadata
10+
COPY --from=builder /rootfs/ /
911

1012
USER 1001
1113

‎extras/dagger/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,27 @@ dagger call -m github.com/chainloop-dev/chainloop \
306306
--key env:SIGNING_KEY
307307
```
308308

309+
### Commit signature verification (from GitLab CI)
310+
311+
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`.
312+
313+
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.
314+
315+
```sh
316+
dagger call -m github.com/chainloop-dev/chainloop \
317+
init \
318+
--token env:CHAINLOOP_TOKEN \
319+
--workflow-name build \
320+
--project-name my-project \
321+
--gitlab-ci=$GITLAB_CI \
322+
--gitlab-server-url=$CI_SERVER_URL \
323+
--gitlab-project-path=$CI_PROJECT_PATH \
324+
--gitlab-job-url=$CI_JOB_URL \
325+
--gitlab-job-token=env:CI_JOB_TOKEN \
326+
--gitlab-token=env:GITLAB_TOKEN \
327+
push
328+
```
329+
309330
### Notes
310331

311332
- All PR/MR parameters are optional - if not provided, attestations work normally without PR metadata

‎extras/dagger/main.go‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import (
1212
const (
1313
chainloopVersion = "v1.118.0"
1414
platformVersion = "v1.124.2"
15+
// gitlabCADir holds the custom CA of the Gitlab server. The CLI image is scratch-based,
16+
// so SSL_CERT_DIR points Go at it, on top of the default ca-certificates.crt bundle
17+
gitlabCADir = "/etc/ssl/gitlab"
1518
)
1619

1720
var execOpts = dagger.ContainerWithExecOpts{
@@ -128,8 +131,12 @@ type ParentCIContext struct {
128131
GitlabMRProjectURL string
129132
// User login
130133
GitlabUserLogin string
131-
// Gitlab job token for API access and commit verification
134+
// Gitlab job token for API access, for example to read merge request details
132135
GitlabJobToken *dagger.Secret
136+
// Gitlab token with the read_api scope for commit verification
137+
GitlabToken *dagger.Secret
138+
// Custom CA certificate of the Gitlab server
139+
GitlabCA *dagger.File
133140
}
134141

135142
// Initialize a new attestation
@@ -224,9 +231,16 @@ func (m *Chainloop) Init(
224231
// Gitlab user login
225232
// +optional
226233
gitlabUserLogin string,
227-
// Gitlab job token for API access and commit verification (when running in Gitlab CI)
234+
// Gitlab job token for API access, for example to read merge request details (when running in Gitlab CI)
228235
// +optional
229236
gitlabJobToken *dagger.Secret,
237+
// Gitlab personal, project or group access token with the read_api scope to verify the commit signature.
238+
// Required for private and internal projects: the commit signature API does not accept job tokens
239+
// +optional
240+
gitlabToken *dagger.Secret,
241+
// Custom CA certificate (PEM) of a self-managed Gitlab server that uses a private CA
242+
// +optional
243+
gitlabCA *dagger.File,
230244
) (*Attestation, error) {
231245
// Construct ParentCIContext from individual parameters
232246
var parentCIContext *ParentCIContext
@@ -255,6 +269,8 @@ func (m *Chainloop) Init(
255269
GitlabMRProjectURL: gitlabMRProjectURL,
256270
GitlabUserLogin: gitlabUserLogin,
257271
GitlabJobToken: gitlabJobToken,
272+
GitlabToken: gitlabToken,
273+
GitlabCA: gitlabCA,
258274
}
259275
}
260276

@@ -655,6 +671,13 @@ func cliContainer(ttl int, token *dagger.Secret, instance InstanceInfo, parentCI
655671
if parentCI.GitlabJobToken != nil {
656672
ctr = ctr.WithSecretVariable("CI_JOB_TOKEN", parentCI.GitlabJobToken)
657673
}
674+
if parentCI.GitlabToken != nil {
675+
ctr = ctr.WithSecretVariable("GITLAB_TOKEN", parentCI.GitlabToken)
676+
}
677+
if parentCI.GitlabCA != nil {
678+
ctr = ctr.WithFile(gitlabCADir+"/ca.pem", parentCI.GitlabCA).
679+
WithEnvVariable("SSL_CERT_DIR", gitlabCADir)
680+
}
658681
}
659682

660683
if token != nil {

‎pkg/attestation/crafter/crafter.go‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error
461461
if headCommit != nil {
462462
// Attempt platform verification
463463
if opts.Runner != nil {
464-
headCommit.PlatformVerification = verifyCommitWithPlatform(headCommit, opts.Runner)
464+
headCommit.PlatformVerification = verifyCommitWithPlatform(headCommit, opts.Runner, opts.Logger)
465465
}
466466

467467
headCommitP = &api.Commit{
@@ -1189,7 +1189,7 @@ func (c *Crafter) requireStateLoaded() error {
11891189

11901190
// verifyCommitWithPlatform attempts to verify commit signature using platform APIs
11911191
// Returns nil if verification is not available or not applicable
1192-
func verifyCommitWithPlatform(commit *HeadCommit, runner SupportedRunner) *api.Commit_CommitVerification {
1192+
func verifyCommitWithPlatform(commit *HeadCommit, runner SupportedRunner, logger *zerolog.Logger) *api.Commit_CommitVerification {
11931193
// Create context with timeout
11941194
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
11951195
defer cancel()
@@ -1200,6 +1200,11 @@ func verifyCommitWithPlatform(commit *HeadCommit, runner SupportedRunner) *api.C
12001200
return nil
12011201
}
12021202

1203+
// The reason is not part of the attestation, so show it to explain the unavailable status
1204+
if verification.Status == commitverification.VerificationStatusUnavailable && logger != nil {
1205+
logger.Warn().Str("platform", verification.Platform).Str("reason", verification.Reason).Msg("commit signature verification unavailable")
1206+
}
1207+
12031208
// Convert from commitverification type to protobuf type
12041209
return convertCommitVerification(verification)
12051210
}

‎pkg/attestation/crafter/runner.go‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
package crafter
1717

1818
import (
19+
"cmp"
1920
"context"
2021
"errors"
2122
"fmt"
23+
"slices"
2224
"time"
2325

2426
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
117119

118120
// DiscoverRunner the runner environment
119121
// This method does a simple check to see which runner is available in the environment
120-
// by iterating over the different runners and performing duck-typing checks.
122+
// by iterating over the different runners in discovery order and performing duck-typing checks.
121123
// It returns the first matching runner immediately to avoid unnecessary CheckEnv()
122124
// calls from remaining runners.
123125
func DiscoverRunner(authToken string, logger zerolog.Logger) SupportedRunner {
124-
for _, factory := range RunnerFactories {
125-
r := factory(authToken, &logger)
126+
for _, runnerType := range runnerDiscoveryOrder() {
127+
r := RunnerFactories[runnerType](authToken, &logger)
126128
if r.CheckEnv() {
127129
return r
128130
}
@@ -131,6 +133,32 @@ func DiscoverRunner(authToken string, logger zerolog.Logger) SupportedRunner {
131133
return runners.NewGeneric()
132134
}
133135

136+
// runnerDiscoveryOrder returns the runner types in the order DiscoverRunner checks them.
137+
// The order must not depend on map iteration, because more than one runner can match the
138+
// same environment. The Dagger runner goes first: the Chainloop Dagger module also passes
139+
// the parent CI context (for example GITLAB_CI and CI_JOB_URL) to the CLI container, so
140+
// the parent CI runner matches too. The other runners follow in enum order.
141+
func runnerDiscoveryOrder() []schemaapi.CraftingSchema_Runner_RunnerType {
142+
order := make([]schemaapi.CraftingSchema_Runner_RunnerType, 0, len(RunnerFactories))
143+
for runnerType := range RunnerFactories {
144+
order = append(order, runnerType)
145+
}
146+
147+
slices.SortFunc(order, func(a, b schemaapi.CraftingSchema_Runner_RunnerType) int {
148+
const first = schemaapi.CraftingSchema_Runner_DAGGER_PIPELINE
149+
switch {
150+
case a == first:
151+
return -1
152+
case b == first:
153+
return 1
154+
default:
155+
return cmp.Compare(a, b)
156+
}
157+
})
158+
159+
return order
160+
}
161+
134162
func DiscoverAndEnforceRunner(enforcedRunnerType schemaapi.CraftingSchema_Runner_RunnerType, dryRun bool, authToken string, logger zerolog.Logger) (SupportedRunner, error) {
135163
discoveredRunner := DiscoverRunner(authToken, logger)
136164

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package crafter
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"testing"
22+
23+
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
24+
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
25+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners"
26+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification"
27+
"github.com/rs/zerolog"
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
// Environments that make the CheckEnv of a runner match
33+
var (
34+
gitlabEnv = map[string]string{"GITLAB_CI": "true", "CI_JOB_URL": "https://gitlab.example.com/group/project/-/jobs/1"}
35+
githubEnv = map[string]string{"CI": "true", "GITHUB_REPOSITORY": "owner/repo", "GITHUB_RUN_ID": "1"}
36+
daggerEnv = map[string]string{"CHAINLOOP_DAGGER_CLIENT": "v1.0.0"}
37+
// otherRunnerEnvVars are read by the CheckEnv of the remaining runners
38+
otherRunnerEnvVars = []string{"TF_BUILD", "BUILD_BUILDURI", "JENKINS_HOME", "BUILD_URL", "CIRCLECI", "TEAMCITY_PROJECT_NAME", "CHAINLOOP_SANDBOX"}
39+
)
40+
41+
func TestDiscoverRunner(t *testing.T) {
42+
testCases := []struct {
43+
name string
44+
envs []map[string]string
45+
want schemaapi.CraftingSchema_Runner_RunnerType
46+
}{
47+
{
48+
name: "no runner environment",
49+
want: schemaapi.CraftingSchema_Runner_RUNNER_TYPE_UNSPECIFIED,
50+
},
51+
{
52+
name: "gitlab pipeline",
53+
envs: []map[string]string{gitlabEnv},
54+
want: schemaapi.CraftingSchema_Runner_GITLAB_PIPELINE,
55+
},
56+
{
57+
name: "github action",
58+
envs: []map[string]string{githubEnv},
59+
want: schemaapi.CraftingSchema_Runner_GITHUB_ACTION,
60+
},
61+
{
62+
// The Chainloop Dagger module passes the parent GitLab CI context to the CLI container
63+
name: "dagger pipeline with gitlab context",
64+
envs: []map[string]string{daggerEnv, gitlabEnv},
65+
want: schemaapi.CraftingSchema_Runner_DAGGER_PIPELINE,
66+
},
67+
{
68+
name: "dagger pipeline with github context",
69+
envs: []map[string]string{daggerEnv, githubEnv},
70+
want: schemaapi.CraftingSchema_Runner_DAGGER_PIPELINE,
71+
},
72+
}
73+
74+
for _, tc := range testCases {
75+
t.Run(tc.name, func(t *testing.T) {
76+
// Clear the runner variables of the environment the test runs in, for example a CI job
77+
for _, env := range []map[string]string{gitlabEnv, githubEnv, daggerEnv} {
78+
for name := range env {
79+
t.Setenv(name, "")
80+
}
81+
}
82+
for _, name := range otherRunnerEnvVars {
83+
t.Setenv(name, "")
84+
}
85+
for _, env := range tc.envs {
86+
for name, value := range env {
87+
t.Setenv(name, value)
88+
}
89+
}
90+
91+
// Discover several times: map iteration order is random, so a
92+
// single call can pick the expected runner by chance
93+
for range 50 {
94+
assert.Equal(t, tc.want, DiscoverRunner("", zerolog.Nop()).ID())
95+
}
96+
})
97+
}
98+
}
99+
100+
// verifyingRunner is a runner that returns a fixed commit verification result
101+
type verifyingRunner struct {
102+
*runners.Generic
103+
verification *commitverification.CommitVerification
104+
}
105+
106+
func (r *verifyingRunner) VerifyCommitSignature(_ context.Context, _ string) *commitverification.CommitVerification {
107+
return r.verification
108+
}
109+
110+
func TestVerifyCommitWithPlatformLogsUnavailableReason(t *testing.T) {
111+
testCases := []struct {
112+
name string
113+
verification *commitverification.CommitVerification
114+
wantStatus api.Commit_CommitVerification_VerificationStatus
115+
wantWarning bool
116+
}{
117+
{
118+
name: "unavailable",
119+
verification: &commitverification.CommitVerification{
120+
Attempted: true,
121+
Status: commitverification.VerificationStatusUnavailable,
122+
Reason: "GitLab API error: x509: certificate signed by unknown authority",
123+
Platform: "gitlab",
124+
},
125+
wantStatus: api.Commit_CommitVerification_unavailable,
126+
wantWarning: true,
127+
},
128+
{
129+
name: "verified",
130+
verification: &commitverification.CommitVerification{
131+
Attempted: true,
132+
Status: commitverification.VerificationStatusVerified,
133+
Reason: "Commit signed and verified",
134+
Platform: "gitlab",
135+
},
136+
wantStatus: api.Commit_CommitVerification_verified,
137+
},
138+
}
139+
140+
for _, tc := range testCases {
141+
t.Run(tc.name, func(t *testing.T) {
142+
var logs bytes.Buffer
143+
logger := zerolog.New(&logs)
144+
runner := &verifyingRunner{Generic: runners.NewGeneric(), verification: tc.verification}
145+
146+
got := verifyCommitWithPlatform(&HeadCommit{Hash: "abc"}, runner, &logger)
147+
148+
require.NotNil(t, got)
149+
assert.Equal(t, tc.wantStatus, got.GetStatus())
150+
if tc.wantWarning {
151+
assert.Contains(t, logs.String(), `"level":"warn"`)
152+
assert.Contains(t, logs.String(), tc.verification.Reason)
153+
} else {
154+
assert.Empty(t, logs.String())
155+
}
156+
})
157+
}
158+
}

‎pkg/attestation/crafter/runners/commitverification/github.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func VerifyGitHubCommit(ctx context.Context, owner, repo, commitHash, token stri
127127
}
128128

129129
// Detect signature type from the signature content
130-
signatureAlgorithm := detectSignatureType(verification.Signature)
130+
signatureAlgorithm := DetectSignatureType(verification.Signature)
131131

132132
if logger != nil {
133133
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
142142
}
143143
}
144144

145-
// detectSignatureType inspects the signature content to determine its type
145+
// DetectSignatureType inspects the signature content to determine its type
146146
// GitHub supports GPG, SSH, and S/MIME signatures
147147
// Format references:
148148
// - Git documentation: https://git-scm.com/docs/gitformat-signature
149149
// - SSH format: https://blog.gitbutler.com/signing-commits-in-git-explained
150-
func detectSignatureType(signature string) string {
150+
func DetectSignatureType(signature string) string {
151151
if signature == "" {
152152
return ""
153153
}

0 commit comments

Comments
 (0)