diff --git a/pkg/attestation/crafter/runner.go b/pkg/attestation/crafter/runner.go index 124f8307e..2813771f2 100644 --- a/pkg/attestation/crafter/runner.go +++ b/pkg/attestation/crafter/runner.go @@ -67,8 +67,8 @@ var RunnerFactories = map[schemaapi.CraftingSchema_Runner_RunnerType]RunnerFacto schemaapi.CraftingSchema_Runner_GITHUB_ACTION: func(logger *zerolog.Logger) SupportedRunner { return runners.NewGithubAction(timeoutCtx, logger) }, - schemaapi.CraftingSchema_Runner_GITLAB_PIPELINE: func(_ *zerolog.Logger) SupportedRunner { - return runners.NewGitlabPipeline() + schemaapi.CraftingSchema_Runner_GITLAB_PIPELINE: func(logger *zerolog.Logger) SupportedRunner { + return runners.NewGitlabPipeline(timeoutCtx, logger) }, schemaapi.CraftingSchema_Runner_AZURE_PIPELINE: func(_ *zerolog.Logger) SupportedRunner { return runners.NewAzurePipeline() diff --git a/pkg/attestation/crafter/runners/githubaction.go b/pkg/attestation/crafter/runners/githubaction.go index 4a33f6a1a..ba3976c7f 100644 --- a/pkg/attestation/crafter/runners/githubaction.go +++ b/pkg/attestation/crafter/runners/githubaction.go @@ -110,7 +110,7 @@ func (r *GitHubAction) Environment() RunnerEnvironment { switch r.githubToken.RunnerEnvironment { case "github-hosted": return Managed - case "self-hosted": + case oidc.SelfHostedRunner: return SelfHosted default: return Unknown diff --git a/pkg/attestation/crafter/runners/gitlabpipeline.go b/pkg/attestation/crafter/runners/gitlabpipeline.go index e537b4ec4..af3543864 100644 --- a/pkg/attestation/crafter/runners/gitlabpipeline.go +++ b/pkg/attestation/crafter/runners/gitlabpipeline.go @@ -16,15 +16,30 @@ package runners import ( + "context" "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/oidc" + "github.com/rs/zerolog" ) -type GitlabPipeline struct{} +type GitlabPipeline struct { + gitlabToken *oidc.GitlabToken +} + +func NewGitlabPipeline(ctx context.Context, logger *zerolog.Logger) *GitlabPipeline { + client, err := oidc.NewGitlabClient(ctx, logger) + if err != nil { + logger.Debug().Err(err).Msgf("failed to create Gitlab OIDC client: %v", err) + return &GitlabPipeline{ + gitlabToken: nil, + } + } -func NewGitlabPipeline() *GitlabPipeline { - return &GitlabPipeline{} + return &GitlabPipeline{ + gitlabToken: client.Token, + } } func (r *GitlabPipeline) ID() schemaapi.CraftingSchema_Runner_RunnerType { @@ -65,13 +80,26 @@ func (r *GitlabPipeline) ResolveEnvVars() (map[string]string, []*error) { } func (r *GitlabPipeline) WorkflowFilePath() string { + if r.gitlabToken != nil { + return r.gitlabToken.ConfigRefURI + } return "" } func (r *GitlabPipeline) IsAuthenticated() bool { - return false + return r.gitlabToken != nil } func (r *GitlabPipeline) Environment() RunnerEnvironment { + if r.gitlabToken != nil { + switch r.gitlabToken.RunnerEnvironment { + case "gitlab-hosted": + return Managed + case oidc.SelfHostedRunner: + return SelfHosted + default: + return Unknown + } + } return Unknown } diff --git a/pkg/attestation/crafter/runners/gitlabpipeline_test.go b/pkg/attestation/crafter/runners/gitlabpipeline_test.go index a339b4824..1e4adca87 100644 --- a/pkg/attestation/crafter/runners/gitlabpipeline_test.go +++ b/pkg/attestation/crafter/runners/gitlabpipeline_test.go @@ -16,9 +16,11 @@ package runners import ( + "context" "os" "testing" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -117,7 +119,8 @@ func (s *gitlabPipelineSuite) TestRunnerName() { // Run before each test func (s *gitlabPipelineSuite) SetupTest() { - s.runner = NewGitlabPipeline() + logger := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled) + s.runner = NewGitlabPipeline(context.Background(), &logger) t := s.T() t.Setenv("GITLAB_CI", "true") t.Setenv("GITLAB_USER_EMAIL", "foo@foo.com") diff --git a/pkg/attestation/crafter/runners/oidc/gitlab.go b/pkg/attestation/crafter/runners/oidc/gitlab.go new file mode 100644 index 000000000..d29668ec6 --- /dev/null +++ b/pkg/attestation/crafter/runners/oidc/gitlab.go @@ -0,0 +1,107 @@ +// +// Copyright 2025 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 oidc + +import ( + "context" + "fmt" + "os" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/rs/zerolog" +) + +// GitlabTokenEnv is the environment variable name for Gitlab OIDC token. +// #nosec G101 - This is just the name of an environment variable, not a credential +const GitlabTokenEnv = "GITLAB_OIDC" + +// CIServerURLEnv is the environment variable name for Gitlab CI server URL. +const CIServerURLEnv = "CI_SERVER_URL" + +type GitlabToken struct { + oidc.IDToken + + // ConfigRefURI is a reference to the current job workflow. + ConfigRefURI string `json:"ci_config_ref_uri"` + + // RunnerEnvironment is the environment the runner is running in. + RunnerEnvironment string `json:"runner_environment"` +} + +type GitlabOIDCClient struct { + Token *GitlabToken +} + +func NewGitlabClient(ctx context.Context, logger *zerolog.Logger) (*GitlabOIDCClient, error) { + var c GitlabOIDCClient + + // retrieve the Gitlab server on which the pipeline is running, which is the provider URL + providerURL := os.Getenv(CIServerURLEnv) + logger.Debug().Str("providerURL", providerURL).Msg("retrieved provider URL") + if providerURL == "" { + return nil, fmt.Errorf("%s environment variable not set", CIServerURLEnv) + } + + tokenContent := os.Getenv(GitlabTokenEnv) + logger.Debug().Msg("retrieved token content") + if tokenContent == "" { + return nil, fmt.Errorf("%s environment variable not set", GitlabTokenEnv) + } + + token, err := parseToken(ctx, providerURL, tokenContent) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + c.Token = token + return &c, nil +} + +func parseToken(ctx context.Context, providerURL string, tokenString string) (*GitlabToken, error) { + provider, err := oidc.NewProvider(ctx, providerURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to OIDC provider: %w", err) + } + + verifier := provider.Verifier(&oidc.Config{ + SkipClientIDCheck: true, // Skip client ID check since we're just parsing + }) + + idToken, err := verifier.Verify(ctx, tokenString) + if err != nil { + return nil, fmt.Errorf("token verification failed: %w", err) + } + + token := &GitlabToken{ + IDToken: *idToken, + } + + // Extract claims to populate our custom fields + var claims map[string]interface{} + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to extract claims: %w", err) + } + + if configRefURI, ok := claims["ci_config_ref_uri"].(string); ok { + token.ConfigRefURI = configRefURI + } + + if runnerEnv, ok := claims["runner_environment"].(string); ok { + token.RunnerEnvironment = runnerEnv + } + + return token, nil +} diff --git a/pkg/attestation/crafter/runners/oidc/gitlab_test.go b/pkg/attestation/crafter/runners/oidc/gitlab_test.go new file mode 100644 index 000000000..934c4b322 --- /dev/null +++ b/pkg/attestation/crafter/runners/oidc/gitlab_test.go @@ -0,0 +1,83 @@ +// +// Copyright 2025 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 oidc_test + +import ( + "context" + "os" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/oidc" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestNewGitlabClient(t *testing.T) { + testLogger := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled) + ctx := context.Background() + + // Save original environment variables + originalServerURL := os.Getenv(oidc.CIServerURLEnv) + originalToken := os.Getenv(oidc.GitlabTokenEnv) + defer func() { + t.Setenv(oidc.CIServerURLEnv, originalServerURL) + t.Setenv(oidc.GitlabTokenEnv, originalToken) + }() + + tests := []struct { + name string + setupEnv func(t *testing.T) + expectErr bool + expectErrContains string + }{ + { + name: "Missing server URL", + setupEnv: func(t *testing.T) { + t.Setenv(oidc.CIServerURLEnv, "") + t.Setenv(oidc.GitlabTokenEnv, "test-token") + }, + expectErr: true, + expectErrContains: "environment variable not set", + }, + { + name: "Missing OIDC token", + setupEnv: func(t *testing.T) { + t.Setenv(oidc.CIServerURLEnv, "https://gitlab.example.com") + t.Setenv(oidc.GitlabTokenEnv, "") + }, + expectErr: true, + expectErrContains: "environment variable not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupEnv(t) + client, err := oidc.NewGitlabClient(ctx, &testLogger) + + if tt.expectErr { + assert.Error(t, err) + if tt.expectErrContains != "" { + assert.Contains(t, err.Error(), tt.expectErrContains) + } + assert.Nil(t, client) + } else { + assert.NoError(t, err) + assert.NotNil(t, client) + } + }) + } +} diff --git a/pkg/attestation/crafter/runners/oidc/oidc.go b/pkg/attestation/crafter/runners/oidc/oidc.go index 77e71758b..a3510a8fb 100644 --- a/pkg/attestation/crafter/runners/oidc/oidc.go +++ b/pkg/attestation/crafter/runners/oidc/oidc.go @@ -20,6 +20,8 @@ import ( "errors" ) +const SelfHostedRunner = "self-hosted" + var ( // errURLError indicates the OIDC server URL is invalid. errURLError = errors.New("url") diff --git a/pkg/attestation/crafter/runners/runners.go b/pkg/attestation/crafter/runners/runners.go index 9ba43c802..c7834a487 100644 --- a/pkg/attestation/crafter/runners/runners.go +++ b/pkg/attestation/crafter/runners/runners.go @@ -18,6 +18,8 @@ package runners import ( "fmt" "os" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/oidc" ) type EnvVarDefinition struct { @@ -62,7 +64,7 @@ func (r RunnerEnvironment) String() string { case Managed: return "managed" case SelfHosted: - return "self-hosted" + return oidc.SelfHostedRunner } return "unknown" }