Skip to content
Merged
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
4 changes: 3 additions & 1 deletion app/cli/Dockerfile.goreleaser
Original file line number Diff line number Diff line change
@@ -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

Expand Down
21 changes: 21 additions & 0 deletions extras/dagger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions extras/dagger/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -255,6 +269,8 @@ func (m *Chainloop) Init(
GitlabMRProjectURL: gitlabMRProjectURL,
GitlabUserLogin: gitlabUserLogin,
GitlabJobToken: gitlabJobToken,
GitlabToken: gitlabToken,
GitlabCA: gitlabCA,
}
}

Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions pkg/attestation/crafter/crafter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down
34 changes: 31 additions & 3 deletions pkg/attestation/crafter/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
package crafter

import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"time"

schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
Expand Down Expand Up @@ -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
}
Expand All @@ -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)

Expand Down
158 changes: 158 additions & 0 deletions pkg/attestation/crafter/runner_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
6 changes: 3 additions & 3 deletions pkg/attestation/crafter/runners/commitverification/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 ""
}
Expand Down
Loading
Loading