From 7d73ba444df4ff61a5082e0ec5f767bcfe3809fd Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Sun, 22 Oct 2023 21:47:32 +0300 Subject: [PATCH] Support gitlab (#123) * Initial GitLab support (via env var) * test: Test buildkite when not in buildkite env This brings ambient.py coverage to 100%. * test: Test detect_env_var() * lint fixes * Add a changelog entry for detect_env_var() * Refactor env var detection to gitlab detection Based on feedback: * Instead of generic env var handling, make the detector only work on gitlab (based on GITLAB_CI variable) * Handle audience args that begin with a digit (replace with "_" in the env var name) * Raise if we are in GitLab environment but token is not found * Tweak README based on these changes This does seem much better as a misconfigured pipeline (e.g. a missing id_tokens section) now results in the following with sigstore: $ python -m sigstore sign README.md An issue occurred with ambient credential detection. Additional context: GitLab: Environment variable SIGSTORE_ID_TOKEN not found which seems pretty good to me. --------- Co-authored-by: Dustin Ingram --- CHANGELOG.md | 4 ++ README.md | 8 +++ id/__init__.py | 2 + id/_internal/oidc/ambient.py | 40 +++++++++++++++ test/unit/internal/oidc/test_ambient.py | 65 +++++++++++++++++++++++++ 5 files changed, 119 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebe080c..2f5c053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +* Added support for GitLab CI/CD ([#123](https://github.com/di/id/pull/123)) + ## [1.1.0] ### Added diff --git a/README.md b/README.md index 1218f85..22425c3 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,14 @@ the OIDC token. This should be set to the intended audience for the token. * [Compute Engine](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances) * and more * [Buildkite](https://buildkite.com/docs/agent/v3/cli-oidc) +* [GitLab](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html) (See _environment variables_ below) + +### Tokens in environment variables + +GitLab provides OIDC tokens through environment variables. The variable name must be +`_ID_TOKEN` where `` is the uppercased audience argument where all +characters outside of ASCII letters and digits are replaced with "_". A leading digit +must also be replaced with a "_". ## Licensing diff --git a/id/__init__.py b/id/__init__.py index db89cc7..4439bc2 100644 --- a/id/__init__.py +++ b/id/__init__.py @@ -61,12 +61,14 @@ def detect_credential(audience: str) -> Optional[str]: detect_buildkite, detect_gcp, detect_github, + detect_gitlab, ) detectors: List[Callable[..., Optional[str]]] = [ detect_github, detect_gcp, detect_buildkite, + detect_gitlab, ] for detector in detectors: credential = detector(audience) diff --git a/id/_internal/oidc/ambient.py b/id/_internal/oidc/ambient.py index af80ba0..eadfb81 100644 --- a/id/_internal/oidc/ambient.py +++ b/id/_internal/oidc/ambient.py @@ -18,6 +18,7 @@ import logging import os +import re import shutil import subprocess # nosec B404 from typing import Optional @@ -34,6 +35,8 @@ _GCP_IDENTITY_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa _GCP_GENERATEIDTOKEN_REQUEST_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken" # noqa +_env_var_regex = re.compile(r"[^A-Z0-9_]|^[^A-Z_]") + class _GitHubTokenPayload(BaseModel): """ @@ -258,3 +261,40 @@ def detect_buildkite(audience: str) -> Optional[str]: ) return process.stdout.strip() + + +def detect_gitlab(audience: str) -> Optional[str]: + """ + Detect and return a GitLab CI/CD ambient OIDC credential. + + This detection is based on an environment variable. The variable name must be + `_ID_TOKEN` where `` is the uppercased audience argument where all + characters outside of ASCII letters and digits are replaced with "_". A + leading digit must also replaced with a "_". + + As an example, audience "sigstore" would require variable SIGSTORE_ID_TOKEN, + and audience "http://test.audience" would require variable + HTTP___TEST_AUDIENCE_ID_TOKEN. + + Returns `None` if the context is not GitLab CI/CD environment. + + Raises if the environment is GitLab, but the `_ID_TOKEN` environment + variable is not set. + """ + logger.debug("GitLab: looking for OIDC credentials") + + if not os.getenv("GITLAB_CI"): + logger.debug("GitLab: environment doesn't look like GitLab CI/CD; giving up") + return None + + # construct a reasonable env var name from the audience + sanitized_audience = _env_var_regex.sub("_", audience.upper()) + var_name = f"{sanitized_audience}_ID_TOKEN" + token = os.getenv(var_name) + if not token: + raise AmbientCredentialError( + f"GitLab: Environment variable {var_name} not found" + ) + + logger.debug(f"GitLab: Found token in environment variable {var_name}") + return token diff --git a/test/unit/internal/oidc/test_ambient.py b/test/unit/internal/oidc/test_ambient.py index 49ba7ad..7f7c22b 100644 --- a/test/unit/internal/oidc/test_ambient.py +++ b/test/unit/internal/oidc/test_ambient.py @@ -641,3 +641,68 @@ def test_buildkite(monkeypatch): text=True, ) ] + + +def test_buildkite_bad_env(monkeypatch): + monkeypatch.delenv("BUILDKITE", False) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + assert ambient.detect_buildkite("some-audience") is None + assert logger.debug.calls == [ + pretend.call("Buildkite: looking for OIDC credentials"), + pretend.call("Buildkite: environment doesn't look like BuildKite; giving up"), + ] + + +def test_gitlab_bad_env(monkeypatch): + monkeypatch.delenv("GITLAB_CI", False) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + assert ambient.detect_gitlab("some-audience") is None + assert logger.debug.calls == [ + pretend.call("GitLab: looking for OIDC credentials"), + pretend.call("GitLab: environment doesn't look like GitLab CI/CD; giving up"), + ] + + +def test_gitlab_no_variable(monkeypatch): + monkeypatch.setenv("GITLAB_CI", "true") + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + with pytest.raises( + ambient.AmbientCredentialError, + match="GitLab: Environment variable SOME_AUDIENCE_ID_TOKEN not found", + ): + ambient.detect_gitlab("some-audience") + + assert logger.debug.calls == [ + pretend.call("GitLab: looking for OIDC credentials"), + ] + + +def test_gitlab(monkeypatch): + monkeypatch.setenv("GITLAB_CI", "true") + monkeypatch.setenv("SOME_AUDIENCE_ID_TOKEN", "fakejwt") + monkeypatch.setenv("_1_OTHER_AUDIENCE_ID_TOKEN", "fakejwt2") + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + assert ambient.detect_gitlab("some-audience") == "fakejwt" + assert ambient.detect_gitlab("11 other audience") == "fakejwt2" + assert logger.debug.calls == [ + pretend.call("GitLab: looking for OIDC credentials"), + pretend.call( + "GitLab: Found token in environment variable SOME_AUDIENCE_ID_TOKEN" + ), + pretend.call("GitLab: looking for OIDC credentials"), + pretend.call( + "GitLab: Found token in environment variable _1_OTHER_AUDIENCE_ID_TOKEN" + ), + ]