diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f4dfad..9b6f4f6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All versions prior to 0.9.0 are untracked. ## [1.1.1] +### Changed + +* Replaced ambient credential detection logic with the `id` package + ([#535](https://github.com/sigstore/sigstore-python/pull/535)) + ### Fixed * Fixed a bug in TUF target handling revealed by changes to the production diff --git a/README.md b/README.md index f65c055d2..5b22ea476 100644 --- a/README.md +++ b/README.md @@ -338,15 +338,8 @@ provided below. ### Signing with ambient credentials For environments that support OpenID Connect, natively `sigstore` supports ambient credential -detection. This includes many popular CI platforms and cloud providers. - -| Service | Status | Notes | -|-----------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| GitHub Actions | Supported | Requires the `id-token` permission; see [the docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) and [this example](https://github.com/sigstore/sigstore-python/blob/main/.github/workflows/release.yml) | -| Google Compute Engine (GCE) | Supported | Automatic | -| Google Cloud Build (GCB) | Supported | Requires setting `GOOGLE_SERVICE_ACCOUNT_NAME` to an appropriately configured service account name; see [the docs](https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-direct) and [this example](https://github.com/sigstore/sigstore-python/blob/main/cloudbuild.yaml) | -| GitLab CI | Planned | See [#31](https://github.com/sigstore/sigstore-python/issues/31) | -| CircleCI | Planned | See [#31](https://github.com/sigstore/sigstore-python/issues/31) | +detection. This includes many popular CI platforms and cloud providers. See the full list of +supported environments [here](https://github.com/di/id#supported-environments). Sign a single file (`foo.txt`) using an ambient OpenID Connect credential, saving the signature and certificate to `foo.txt.sig` and `foo.txt.crt`: diff --git a/pyproject.toml b/pyproject.toml index ff9c000d7..28dd771f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ dependencies = [ "appdirs ~= 1.4", "cryptography >= 39", + "id >= 1.0.0", "importlib_resources ~= 5.7; python_version < '3.11'", "pydantic ~= 1.10", "pyjwt >= 2.1", diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 7fcfb3a86..4f2d02945 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -24,12 +24,14 @@ from typing import Optional, TextIO, Union, cast from cryptography.x509 import load_pem_x509_certificates +from id import GitHubOidcPermissionCredentialError, detect_credential from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import Bundle from sigstore import __version__ from sigstore._internal.ctfe import CTKeyring from sigstore._internal.fulcio.client import DEFAULT_FULCIO_URL, FulcioClient from sigstore._internal.keyring import Keyring +from sigstore._internal.oidc import DEFAULT_AUDIENCE from sigstore._internal.rekor.client import ( DEFAULT_REKOR_URL, RekorClient, @@ -40,9 +42,7 @@ from sigstore.oidc import ( DEFAULT_OAUTH_ISSUER_URL, STAGING_OAUTH_ISSUER_URL, - GitHubOidcPermissionCredentialError, Issuer, - detect_credential, ) from sigstore.sign import Signer from sigstore.transparency import LogEntry @@ -1012,7 +1012,7 @@ def _get_identity_token(args: argparse.Namespace) -> Optional[str]: token = None if not args.oidc_disable_ambient_providers: try: - token = detect_credential() + token = detect_credential(DEFAULT_AUDIENCE) except GitHubOidcPermissionCredentialError as exception: # Provide some common reasons for why we hit permission errors in # GitHub Actions. diff --git a/sigstore/_internal/oidc/__init__.py b/sigstore/_internal/oidc/__init__.py index 6e1ac6b20..09ade9630 100644 --- a/sigstore/_internal/oidc/__init__.py +++ b/sigstore/_internal/oidc/__init__.py @@ -17,8 +17,7 @@ """ import jwt - -from sigstore.oidc import IdentityError +from id import IdentityError # See: https://github.com/sigstore/fulcio/blob/b2186c0/pkg/config/config.go#L182-L201 _KNOWN_OIDC_ISSUERS = { diff --git a/sigstore/_internal/oidc/ambient.py b/sigstore/_internal/oidc/ambient.py deleted file mode 100644 index 8dded663f..000000000 --- a/sigstore/_internal/oidc/ambient.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright 2022 The Sigstore 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. - -""" -Ambient OIDC credential detection for sigstore. -""" - -import logging -import os -from typing import Optional - -import requests -from pydantic import BaseModel, StrictStr - -from sigstore._internal.oidc import DEFAULT_AUDIENCE -from sigstore.oidc import ( - AmbientCredentialError, - GitHubOidcPermissionCredentialError, -) - -logger = logging.getLogger(__name__) - -_GCP_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name" -_GCP_TOKEN_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/token" # noqa # nosec B105 -_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 - - -class _GitHubTokenPayload(BaseModel): - """ - A trivial model for GitHub's OIDC token endpoint payload. - - This exists solely to provide nice error handling. - """ - - value: StrictStr - - -def detect_github() -> Optional[str]: - """ - Detect and return a GitHub Actions ambient OIDC credential. - - Returns `None` if the context is not a GitHub Actions environment. - - Raises if the environment is GitHub Actions, but is incorrect or - insufficiently permissioned for an OIDC credential. - """ - - logger.debug("GitHub: looking for OIDC credentials") - if not os.getenv("GITHUB_ACTIONS"): - logger.debug("GitHub: environment doesn't look like a GH action; giving up") - return None - - # If we're running on a GitHub Action, we need to issue a GET request - # to a special URL with a special bearer token. Both are stored in - # the environment and are only present if the workflow has sufficient permissions. - req_token = os.getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - if not req_token: - raise GitHubOidcPermissionCredentialError( - "GitHub: missing or insufficient OIDC token permissions, the " - "ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable was unset" - ) - req_url = os.getenv("ACTIONS_ID_TOKEN_REQUEST_URL") - if not req_url: - raise GitHubOidcPermissionCredentialError( - "GitHub: missing or insufficient OIDC token permissions, the " - "ACTIONS_ID_TOKEN_REQUEST_URL environment variable was unset" - ) - - logger.debug("GitHub: requesting OIDC token") - resp = requests.get( - req_url, - params={"audience": DEFAULT_AUDIENCE}, - headers={"Authorization": f"bearer {req_token}"}, - ) - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GitHub: OIDC token request failed (code={resp.status_code})" - ) from http_error - - try: - payload = _GitHubTokenPayload.parse_obj(resp.json()) - except ValueError as e: - raise AmbientCredentialError(f"GitHub: malformed or incomplete JSON: {e}") - - logger.debug("GCP: successfully requested OIDC token") - return payload.value - - -def detect_gcp() -> Optional[str]: - """ - Detect an return a Google Cloud Platform ambient OIDC credential. - - Returns `None` if the context is not a GCP environment. - - Raises if the environment is GCP, but is incorrect or - insufficiently permissioned for an OIDC credential. - """ - logger.debug("GCP: looking for OIDC credentials") - - service_account_name = os.getenv("GOOGLE_SERVICE_ACCOUNT_NAME") - if service_account_name: - logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation") - - logger.debug("GCP: requesting access token") - resp = requests.get( - _GCP_TOKEN_REQUEST_URL, - params={"scopes": "https://www.googleapis.com/auth/cloud-platform"}, - headers={"Metadata-Flavor": "Google"}, - ) - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: access token request failed (code={resp.status_code})" - ) from http_error - - access_token = resp.json().get("access_token") - - if not access_token: - raise AmbientCredentialError("GCP: access token missing from response") - - resp = requests.post( - _GCP_GENERATEIDTOKEN_REQUEST_URL.format(service_account_name), - json={"audience": DEFAULT_AUDIENCE, "includeEmail": True}, - headers={ - "Authorization": f"Bearer {access_token}", - }, - ) - - logger.debug("GCP: requesting OIDC token") - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: OIDC token request failed (code={resp.status_code})" - ) from http_error - - oidc_token: str = resp.json().get("token") - - if not oidc_token: - raise AmbientCredentialError("GCP: OIDC token missing from response") - - logger.debug("GCP: successfully requested OIDC token") - return oidc_token - - else: - logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation") - - try: - with open(_GCP_PRODUCT_NAME_FILE) as f: - name = f.read().strip() - except OSError: - logger.debug( - "GCP: environment doesn't have GCP product name file; giving up" - ) - return None - - if name not in {"Google", "Google Compute Engine"}: - logger.debug( - f"GCP: product name file exists, but product name is {name!r}; giving up" - ) - return None - - logger.debug("GCP: requesting OIDC token") - resp = requests.get( - _GCP_IDENTITY_REQUEST_URL, - params={"audience": DEFAULT_AUDIENCE, "format": "full"}, - headers={"Metadata-Flavor": "Google"}, - ) - - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: OIDC token request failed (code={resp.status_code})" - ) from http_error - - logger.debug("GCP: successfully requested OIDC token") - return resp.text diff --git a/sigstore/_internal/oidc/oauth.py b/sigstore/_internal/oidc/oauth.py index 582268b75..7bb35db11 100644 --- a/sigstore/_internal/oidc/oauth.py +++ b/sigstore/_internal/oidc/oauth.py @@ -28,8 +28,10 @@ import uuid from typing import Any, Dict, List, Optional, cast +from id import IdentityError + from sigstore._utils import B64Str -from sigstore.oidc import IdentityError, Issuer +from sigstore.oidc import Issuer logger = logging.getLogger(__name__) diff --git a/sigstore/oidc.py b/sigstore/oidc.py index 0f2ab83c8..76b221b28 100644 --- a/sigstore/oidc.py +++ b/sigstore/oidc.py @@ -23,9 +23,9 @@ import time import urllib.parse import webbrowser -from typing import Callable, List, Optional import requests +from id import IdentityError from pydantic import BaseModel, StrictStr DEFAULT_OAUTH_ISSUER_URL = "https://oauth2.sigstore.dev/auth" @@ -169,47 +169,3 @@ def identity_token( # nosec: B107 raise IdentityError(f"Error response from token endpoint: {token_error}") return str(token_json["access_token"]) - - -class IdentityError(Exception): - """ - Raised on any OIDC token format or claim error. - """ - - pass - - -class AmbientCredentialError(IdentityError): - """ - Raised when an ambient credential should be present, but - can't be retrieved (e.g. network failure). - """ - - pass - - -class GitHubOidcPermissionCredentialError(AmbientCredentialError): - """ - Raised when the current GitHub Actions environment doesn't have permission - to retrieve an OIDC token. - """ - - pass - - -def detect_credential() -> Optional[str]: - """ - Try each ambient credential detector, returning the first one to succeed - or `None` if all fail. - - Raises `AmbientCredentialError` if any detector fails internally (i.e. - detects a credential, but cannot retrieve it). - """ - from sigstore._internal.oidc.ambient import detect_gcp, detect_github - - detectors: List[Callable[..., Optional[str]]] = [detect_github, detect_gcp] - for detector in detectors: - credential = detector() - if credential is not None: - return credential - return None diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 517071f50..77ef8a63d 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -20,16 +20,17 @@ from typing import Iterator import pytest +from id import ( + AmbientCredentialError, + GitHubOidcPermissionCredentialError, + detect_credential, +) from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import Bundle from tuf.api.exceptions import DownloadHTTPError from tuf.ngclient import FetcherInterface from sigstore._internal import tuf -from sigstore.oidc import ( - AmbientCredentialError, - GitHubOidcPermissionCredentialError, - detect_credential, -) +from sigstore._internal.oidc import DEFAULT_AUDIENCE from sigstore.verify import VerificationMaterials from sigstore.verify.policy import VerificationSuccess @@ -42,7 +43,7 @@ def _is_ambient_env(): try: - token = detect_credential() + token = detect_credential(DEFAULT_AUDIENCE) if token is None: return False except GitHubOidcPermissionCredentialError: diff --git a/test/unit/internal/oidc/test_ambient.py b/test/unit/internal/oidc/test_ambient.py deleted file mode 100644 index 2cf5fdcbc..000000000 --- a/test/unit/internal/oidc/test_ambient.py +++ /dev/null @@ -1,417 +0,0 @@ -# Copyright 2022 The Sigstore 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. - -import pretend -import pytest -from requests import HTTPError - -from sigstore._internal.oidc import ambient -from sigstore.oidc import detect_credential - - -def test_detect_credential_none(monkeypatch): - detect_none = pretend.call_recorder(lambda: None) - monkeypatch.setattr(ambient, "detect_github", detect_none) - monkeypatch.setattr(ambient, "detect_gcp", detect_none) - assert detect_credential() is None - - -def test_detect_credential(monkeypatch): - detect_github = pretend.call_recorder(lambda: "fakejwt") - monkeypatch.setattr(ambient, "detect_github", detect_github) - - assert detect_credential() == "fakejwt" - - -def test_detect_github_bad_env(monkeypatch): - # We might actually be running in a CI, so explicitly remove this. - monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - assert ambient.detect_github() is None - assert logger.debug.calls == [ - pretend.call("GitHub: looking for OIDC credentials"), - pretend.call("GitHub: environment doesn't look like a GH action; giving up"), - ] - - -def test_detect_github_bad_request_token(monkeypatch): - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.delenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", raising=False) - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - with pytest.raises( - ambient.AmbientCredentialError, - match="GitHub: missing or insufficient OIDC token permissions?", - ): - ambient.detect_github() - assert logger.debug.calls == [ - pretend.call("GitHub: looking for OIDC credentials"), - ] - - -def test_detect_github_bad_request_url(monkeypatch): - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.delenv("ACTIONS_ID_TOKEN_REQUEST_URL", raising=False) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - with pytest.raises( - ambient.AmbientCredentialError, - match="GitHub: missing or insufficient OIDC token permissions?", - ): - ambient.detect_github() - assert logger.debug.calls == [ - pretend.call("GitHub: looking for OIDC credentials"), - ] - - -def test_detect_github_request_fails(monkeypatch): - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - - resp = pretend.stub(raise_for_status=pretend.raiser(HTTPError), status_code=999) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError - ) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match=r"GitHub: OIDC token request failed \(code=999\)", - ): - ambient.detect_github() - assert requests.get.calls == [ - pretend.call( - "fakeurl", - params={"audience": "sigstore"}, - headers={"Authorization": "bearer faketoken"}, - ) - ] - - -def test_detect_github_bad_payload(monkeypatch): - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - - resp = pretend.stub( - raise_for_status=lambda: None, json=pretend.call_recorder(lambda: {}) - ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match="GitHub: malformed or incomplete JSON", - ): - ambient.detect_github() - assert requests.get.calls == [ - pretend.call( - "fakeurl", - params={"audience": "sigstore"}, - headers={"Authorization": "bearer faketoken"}, - ) - ] - assert resp.json.calls == [pretend.call()] - - -def test_detect_github(monkeypatch): - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "faketoken") - monkeypatch.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "fakeurl") - - resp = pretend.stub( - raise_for_status=lambda: None, - json=pretend.call_recorder(lambda: {"value": "fakejwt"}), - ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) - - assert ambient.detect_github() == "fakejwt" - assert requests.get.calls == [ - pretend.call( - "fakeurl", - params={"audience": "sigstore"}, - headers={"Authorization": "bearer faketoken"}, - ) - ] - assert resp.json.calls == [pretend.call()] - - -def test_gcp_impersonation_access_token_request_fail(monkeypatch): - monkeypatch.setenv( - "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" - ) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - resp = pretend.stub(raise_for_status=pretend.raiser(HTTPError), status_code=999) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError - ) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match=r"GCP: access token request failed \(code=999\)", - ): - ambient.detect_gcp() - - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), - pretend.call("GCP: requesting access token"), - ] - - -def test_gcp_impersonation_access_token_missing(monkeypatch): - monkeypatch.setenv( - "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" - ) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {}) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match=r"GCP: access token missing from response", - ): - ambient.detect_gcp() - - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), - pretend.call("GCP: requesting access token"), - ] - - -def test_gcp_impersonation_identity_token_request_fail(monkeypatch): - monkeypatch.setenv( - "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" - ) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - access_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) - post_resp = pretend.stub( - raise_for_status=pretend.raiser(HTTPError), status_code=999 - ) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, - ) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match=r"GCP: OIDC token request failed \(code=999\)", - ): - ambient.detect_gcp() - - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), - pretend.call("GCP: requesting access token"), - pretend.call("GCP: requesting OIDC token"), - ] - - -def test_gcp_impersonation_identity_token_missing(monkeypatch): - monkeypatch.setenv( - "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" - ) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - access_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) - post_resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {}) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, - ) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match=r"GCP: OIDC token missing from response", - ): - ambient.detect_gcp() - - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), - pretend.call("GCP: requesting access token"), - pretend.call("GCP: requesting OIDC token"), - ] - - -def test_gcp_impersonation_succeeds(monkeypatch): - monkeypatch.setenv( - "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" - ) - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - access_token = pretend.stub() - oidc_token = pretend.stub() - get_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"access_token": access_token} - ) - post_resp = pretend.stub( - raise_for_status=lambda: None, json=lambda: {"token": oidc_token} - ) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: get_resp), - post=pretend.call_recorder(lambda url, **kw: post_resp), - HTTPError=HTTPError, - ) - monkeypatch.setattr(ambient, "requests", requests) - - assert ambient.detect_gcp() == oidc_token - - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), - pretend.call("GCP: requesting access token"), - pretend.call("GCP: requesting OIDC token"), - pretend.call("GCP: successfully requested OIDC token"), - ] - - -def test_gcp_bad_env(monkeypatch): - oserror = pretend.raiser(OSError) - monkeypatch.setitem(ambient.__builtins__, "open", oserror) # type: ignore - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - assert ambient.detect_gcp() is None - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call( - "GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation" - ), - pretend.call("GCP: environment doesn't have GCP product name file; giving up"), - ] - - -def test_gcp_wrong_product(monkeypatch): - stub_file = pretend.stub( - __enter__=lambda *a: pretend.stub(read=lambda: "Unsupported Product"), - __exit__=lambda *a: None, - ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - assert ambient.detect_gcp() is None - - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call( - "GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation" - ), - pretend.call( - "GCP: product name file exists, but product name is 'Unsupported Product'; giving up" - ), - ] - - -def test_detect_gcp_request_fails(monkeypatch): - stub_file = pretend.stub( - __enter__=lambda *a: pretend.stub(read=lambda: "Google"), - __exit__=lambda *a: None, - ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore - - resp = pretend.stub(raise_for_status=pretend.raiser(HTTPError), status_code=999) - requests = pretend.stub( - get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError - ) - monkeypatch.setattr(ambient, "requests", requests) - - with pytest.raises( - ambient.AmbientCredentialError, - match=r"GCP: OIDC token request failed \(code=999\)", - ): - ambient.detect_gcp() - assert requests.get.calls == [ - pretend.call( - ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "sigstore", "format": "full"}, - headers={"Metadata-Flavor": "Google"}, - ) - ] - - -@pytest.mark.parametrize("product_name", ("Google", "Google Compute Engine")) -def test_detect_gcp(monkeypatch, product_name): - stub_file = pretend.stub( - __enter__=lambda *a: pretend.stub(read=lambda: product_name), - __exit__=lambda *a: None, - ) - monkeypatch.setitem(ambient.__builtins__, "open", lambda fn: stub_file) # type: ignore - - logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) - monkeypatch.setattr(ambient, "logger", logger) - - resp = pretend.stub( - raise_for_status=lambda: None, - text="fakejwt", - ) - requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) - monkeypatch.setattr(ambient, "requests", requests) - - assert ambient.detect_gcp() == "fakejwt" - assert requests.get.calls == [ - pretend.call( - ambient._GCP_IDENTITY_REQUEST_URL, - params={"audience": "sigstore", "format": "full"}, - headers={"Metadata-Flavor": "Google"}, - ) - ] - assert logger.debug.calls == [ - pretend.call("GCP: looking for OIDC credentials"), - pretend.call( - "GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation" - ), - pretend.call("GCP: requesting OIDC token"), - pretend.call("GCP: successfully requested OIDC token"), - ] diff --git a/test/unit/internal/oidc/test_issuer.py b/test/unit/internal/oidc/test_issuer.py index b3458b28c..361d26707 100644 --- a/test/unit/internal/oidc/test_issuer.py +++ b/test/unit/internal/oidc/test_issuer.py @@ -13,8 +13,9 @@ # limitations under the License. import pytest +from id import IdentityError -from sigstore.oidc import IdentityError, Issuer, IssuerError +from sigstore.oidc import Issuer, IssuerError @pytest.mark.online diff --git a/test/unit/test_sign.py b/test/unit/test_sign.py index f282f5d10..6a5e71d47 100644 --- a/test/unit/test_sign.py +++ b/test/unit/test_sign.py @@ -18,11 +18,12 @@ import jwt import pretend import pytest +from id import IdentityError, detect_credential import sigstore._internal.oidc from sigstore._internal.keyring import KeyringError, KeyringLookupError +from sigstore._internal.oidc import DEFAULT_AUDIENCE from sigstore._internal.sct import InvalidSCTError -from sigstore.oidc import IdentityError, detect_credential from sigstore.sign import Signer @@ -45,7 +46,7 @@ def test_sign_rekor_entry_consistent(signer): # expansion doesn't fail in offline tests. signer = signer() - token = detect_credential() + token = detect_credential(DEFAULT_AUDIENCE) assert token is not None payload = io.BytesIO(secrets.token_bytes(32)) @@ -67,7 +68,7 @@ def test_sct_verify_keyring_lookup_error(signer, monkeypatch): signer = signer() signer._rekor._ct_keyring = pretend.stub(verify=pretend.raiser(KeyringLookupError)) - token = detect_credential() + token = detect_credential(DEFAULT_AUDIENCE) assert token is not None payload = io.BytesIO(secrets.token_bytes(32)) @@ -87,7 +88,7 @@ def test_sct_verify_keyring_error(signer, monkeypatch): signer = signer() signer._rekor._ct_keyring = pretend.stub(verify=pretend.raiser(KeyringError)) - token = detect_credential() + token = detect_credential(DEFAULT_AUDIENCE) assert token is not None payload = io.BytesIO(secrets.token_bytes(32)) @@ -102,7 +103,7 @@ def test_sct_verify_keyring_error(signer, monkeypatch): def test_identity_proof_claim_lookup(signer, monkeypatch): signer = signer() - token = detect_credential() + token = detect_credential(DEFAULT_AUDIENCE) assert token is not None # clear out the known issuers, forcing the `Identity`'s `proof_claim` to be looked up.