pax_global_header00006660000000000000000000000064147241312230014511gustar00rootroot0000000000000052 comment=1f665ce45ab0ecd4ce6e1f85088df44a09a2adf8 id-1.5.0/000077500000000000000000000000001472413122300121105ustar00rootroot00000000000000id-1.5.0/.circleci/000077500000000000000000000000001472413122300137435ustar00rootroot00000000000000id-1.5.0/.circleci/config.yml000066400000000000000000000023741472413122300157410ustar00rootroot00000000000000# Use the latest 2.1 version of CircleCI pipeline process engine. # See: https://circleci.com/docs/configuration-reference version: 2.1 # Define a job to be invoked later in a workflow. # See: https://circleci.com/docs/configuration-reference/#jobs jobs: test-id-throwaway: # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub. # See: https://circleci.com/docs/configuration-reference/#executor-job docker: - image: cimg/python:3.12.1 # Add steps to the job # See: https://circleci.com/docs/configuration-reference/#steps steps: - checkout - run: name: Install id command: | python -m pip install . --user - run: name: Generate throwaway credential command: | # Flush to /dev/null as a conservative measure: this token # shouldn't be sensitive or used by anything, but there's # also no reason to leak it by default. python -m id throwaway > /dev/null # Orchestrate jobs using workflows # See: https://circleci.com/docs/configuration-reference/#workflows workflows: test-id-throwaway-workflow: jobs: - test-id-throwaway id-1.5.0/.github/000077500000000000000000000000001472413122300134505ustar00rootroot00000000000000id-1.5.0/.github/dependabot.yml000066400000000000000000000004771472413122300163100ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: / schedule: interval: daily - package-ecosystem: github-actions directory: / schedule: interval: daily open-pull-requests-limit: 99 rebase-strategy: "disabled" groups: actions: patterns: - "*" id-1.5.0/.github/workflows/000077500000000000000000000000001472413122300155055ustar00rootroot00000000000000id-1.5.0/.github/workflows/ci.yml000066400000000000000000000031251472413122300166240ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: schedule: - cron: '0 12 * * *' jobs: test: permissions: # Needed to access the workflow's OIDC identity. id-token: write strategy: matrix: conf: - { py: "3.8", os: "ubuntu-latest" } - { py: "3.9", os: "ubuntu-latest" } - { py: "3.10", os: "ubuntu-latest" } - { py: "3.11", os: "ubuntu-latest" } - { py: "3.12", os: "ubuntu-latest" } - { py: "3.13", os: "ubuntu-latest" } # NOTE: We only test Windows and macOS on the latest Python; # these primarily exist to ensure that we don't accidentally # introduce Linux-isms into the development tooling. - { py: "3.13", os: "windows-latest" } - { py: "3.13", os: "macos-latest" } runs-on: ${{ matrix.conf.os }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: ${{ matrix.conf.py }} cache: "pip" cache-dependency-path: pyproject.toml - name: deps run: make dev ID_EXTRA=test - name: test run: make test TEST_ARGS="-vv --showlocals" all-tests-pass: if: always() needs: - test runs-on: ubuntu-latest steps: - name: check test jobs uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ toJSON(needs) }} id-1.5.0/.github/workflows/lint.yml000066400000000000000000000037611472413122300172050ustar00rootroot00000000000000name: Lint on: push: branches: - main pull_request: jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false # NOTE: We intentionally lint against our minimum supported Python. - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: "3.8" cache: "pip" cache-dependency-path: pyproject.toml - name: deps run: make dev ID_EXTRA=lint - name: lint run: make lint check-readme: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false # NOTE: We intentionally check `--help` rendering against our minimum Python, # since it changes slightly between Python versions. - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: "3.8" cache: "pip" cache-dependency-path: pyproject.toml - name: deps run: make dev - name: check-readme run: make check-readme licenses: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false # adapted from Warehouse's bin/licenses - run: | for fn in $(find . -type f -name "*.py"); do if [[ ! "$(head -5 $fn | grep "^ *\(#\|\*\|\/\/\) .* License\(d*\)")" ]]; then echo "${fn} is missing a license" exit 1 fi done all-lints-pass: if: always() needs: - lint - check-readme - licenses runs-on: ubuntu-latest steps: - name: check lint jobs uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ toJSON(needs) }} id-1.5.0/.github/workflows/release.yml000066400000000000000000000063741472413122300176620ustar00rootroot00000000000000name: Release on: release: types: - published permissions: # added using https://github.com/step-security/secure-workflows contents: read jobs: build: name: Build artifacts runs-on: ubuntu-latest outputs: hashes: ${{ steps.hash.outputs.hashes }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: "3.x" cache: "pip" cache-dependency-path: pyproject.toml - name: deps run: python -m pip install -U build - name: build run: python -m build - name: Generate hashes for provenance shell: bash id: hash run: | # sha256sum generates sha256 hash for all artifacts. # base64 -w0 encodes to base64 and outputs on a single line. # sha256sum artifact1 artifact2 ... | base64 -w0 echo "hashes=$(sha256sum ./dist/* | base64 -w0)" >> $GITHUB_OUTPUT - name: Upload built packages uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: built-packages path: ./dist/ if-no-files-found: warn generate-provenance: needs: [build] name: Generate build provenance permissions: actions: read # To read the workflow path. id-token: write # To sign the provenance. contents: write # To add assets to a release. # Currently this action needs to be referred by tag. More details at: # https://github.com/slsa-framework/slsa-github-generator#verification-of-provenance uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 with: provenance-name: provenance-id-${{ github.event.release.tag_name }}.intoto.jsonl base64-subjects: "${{ needs.build.outputs.hashes }}" upload-assets: true release-pypi: needs: [build, generate-provenance] runs-on: ubuntu-latest permissions: id-token: write # To upload via OIDC + generate attestations. steps: - name: Download artifacts directories # goes to current working directory uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - name: publish uses: pypa/gh-action-pypi-publish@15c56dba361d8335944d31a2ecd17d700fc7bcbc # v1.12.2 with: packages-dir: built-packages/ attestations: true release-github: needs: [build, generate-provenance] runs-on: ubuntu-latest permissions: # Needed to upload release assets. contents: write steps: - name: Download artifacts directories # goes to current working directory uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - name: Upload artifacts to GitHub # Confusingly, this action also supports updating releases, not # just creating them. This is what we want here, since we've manually # created the release that triggered the action. uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0 with: files: | built-packages/* id-1.5.0/.github/workflows/scorecards-analysis.yml000066400000000000000000000037521472413122300222100ustar00rootroot00000000000000name: Scorecards supply-chain security on: # Only the default branch is supported. workflow_dispatch: # Manual branch_protection_rule: schedule: - cron: '30 4 * * 0' push: branches: [ main ] permissions: {} # Remove all job-level permissions. jobs: analysis: name: Scorecards analysis runs-on: ubuntu-latest permissions: # Needed to upload the results to code-scanning dashboard. security-events: write actions: read contents: read # Needed to access GitHub's OIDC token which ensures the uploaded results integrity. id-token: write steps: - name: "Checkout code" uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif # Read-only PAT token. To create it, # follow the steps in https://github.com/ossf/scorecard-action#pat-token-creation. repo_token: ${{ secrets.SCORECARD_TOKEN }} # Publish the results to enable scorecard badges. For more details, see # https://github.com/ossf/scorecard-action#publishing-results. # For private repositories, `publish_results` will automatically be set to `false`, # regardless of the value entered here. publish_results: true # Upload the results as artifacts (optional). - name: "Upload artifact" uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v2.3.1 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6 with: sarif_file: results.sarif id-1.5.0/.gitignore000066400000000000000000000001371472413122300141010ustar00rootroot00000000000000env/ pip-wheel-metadata/ *.egg-info/ __pycache__/ .coverage* html/ dist/ .python-version build id-1.5.0/CHANGELOG.md000066400000000000000000000032201472413122300137160ustar00rootroot00000000000000# Changelog All notable changes to `id` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ## [1.5.0] ### Changed * Drop dependency on `pydantic` ([#320](https://github.com/di/id/pull/320)) ## [1.4.0] ### Added * Add `pipx run` entry point ([#217](https://github.com/di/id/pull/217)) ## [1.3.0] ### Added * Add support for decoding tokens with `-d`/`--decode` ([#162](https://github.com/di/id/pull/162)) ## [1.2.1] ### Misc * This release fixes a deployment bug in the 1.2.0 release. ## [1.2.0] ### Added * Added support for GitLab CI/CD ([#123](https://github.com/di/id/pull/123)) * Added support for CircleCI ([#144](https://github.com/di/id/pull/144)) ### Changed * The minimum supported Python version is now 3.8 ([#141](https://github.com/di/id/pull/141)) ## [1.1.0] ### Added * Added support for Buildkite OIDC tokens ([#21](https://github.com/di/id/pull/21)) ### Fixed * Improved the quality of error messages when an underlying request fails ([#93](https://github.com/di/id/pull/93)) ## [1.0.0] ### Added * Initial split from https://github.com/sigstore/sigstore-python [Unreleased]: https://github.com/di/id/compare/v1.5.0...HEAD [1.5.0]: https://github.com/di/id/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/di/id/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/di/id/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/di/id/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/di/id/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/di/id/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/di/id/compare/v1.0.0a2...v1.0.0 id-1.5.0/CONTRIBUTING.md000066400000000000000000000100311472413122300143340ustar00rootroot00000000000000Contributing to id ================== Thank you for your interest in contributing to `id`! The information below will help you set up a local development environment, as well as performing common development tasks. ## Requirements `id`'s only development environment requirement *should* be Python 3.8 or newer. Development and testing is actively performed on macOS and Linux, but Windows and other supported platforms that are supported by Python should also work. If you're on a system that has GNU Make, you can use the convenience targets included in the `Makefile` that comes in the `id` repository detailed below. But this isn't required; all steps can be done without Make. ## Development steps First, clone this repository: ```bash git clone https://github.com/di/id cd id ``` Then, use one of the `Makefile` targets to run a task. The first time this is run, this will also set up the local development virtual environment, and will install `id` as an editable package into this environment. Any changes you make to the `id` source tree will take effect immediately in the virtual environment. ### Linting You can lint locally with: ```bash make lint ``` `id` is automatically linted and formatted with a collection of tools: * [`black`](https://github.com/psf/black): Code formatting * [`isort`](https://github.com/PyCQA/isort): Import sorting, ordering * [`ruff`](https://github.com/charliermarsh/ruff): PEP-8 linting, style enforcement * [`mypy`](https://mypy.readthedocs.io/en/stable/): Static type checking * [`bandit`](https://github.com/PyCQA/bandit): Security issue scanning * [`interrogate`](https://interrogate.readthedocs.io/en/latest/): Documentation coverage To automatically apply any lint-suggested changes, you can run: ```bash make reformat ``` ### Testing You can run the tests locally with: ```bash make test ``` You can also filter by a pattern (uses `pytest -k`): ```bash make test TESTS=test_version ``` To test a specific file: ```bash make test T=path/to/file.py ``` `id` has a [`pytest`](https://docs.pytest.org/)-based unit test suite, including code coverage with [`coverage.py`](https://coverage.readthedocs.io/). ### Releasing **NOTE**: If you're a non-maintaining contributor, you don't need the steps here! They're documented for completeness and for onboarding future maintainers. Releases of `id` are managed with [`bump`](https://github.com/di/bump) and GitHub Actions. ```bash # default release (patch bump) make release # override the default # vX.Y.Z -> vX.Y.Z-rc.0 make release BUMP_ARGS="--pre rc.0" # vX.Y.Z -> vN.0.0 make release BUMP_ARGS="--major" ``` `make release` will fail if there are any untracked changes in the source tree. If `make release` succeeds, you'll see an output like this: ``` RUN ME MANUALLY: git push origin main && git push origin vX.Y.Z ``` Run that last command sequence to complete the release. ## Development practices Here are some guidelines to follow if you're working on a new feature or changes to `id`'s internal APIs: * *Keep the `id` APIs as private as possible*. Nearly all of `id`'s APIs should be private and treated as unstable and unsuitable for public use. If you're adding a new module to the source tree, prefix the filename with an underscore to emphasize that it's an internal (e.g., `id/_foo.py` instead of `id/foo.py`). * *Perform judicious debug logging.* `id` uses the standard Python [`logging`](https://docs.python.org/3/library/logging.html) module. Use `logger.debug` early and often -- users who experience errors can submit better bug reports when their debug logs include helpful context! * *Update the [CHANGELOG](./CHANGELOG.md)*. If your changes are public or result in changes to `id`'s CLI, please record them under the "Unreleased" section, with an entry in an appropriate subsection ("Added", "Changed", "Removed", or "Fixed"). * Ensure your commits are signed off, as `id` uses the [DCO](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin). You can do it using `git commit -s`, or `git commit -s --amend` if you want to amend already existing commits. id-1.5.0/COPYRIGHT.txt000066400000000000000000000010621472413122300142200ustar00rootroot00000000000000Copyright 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. id-1.5.0/LICENSE000066400000000000000000000261361472413122300131250ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. id-1.5.0/Makefile000066400000000000000000000053561472413122300135610ustar00rootroot00000000000000SHELL := /bin/bash PY_MODULE := id ALL_PY_SRCS := $(shell find $(PY_MODULE) -name '*.py') \ $(shell find test -name '*.py') # Optionally overriden by the user, if they're using a virtual environment manager. VENV ?= env # On Windows, venv scripts/shims are under `Scripts` instead of `bin`. VENV_BIN := $(VENV)/bin ifeq ($(OS),Windows_NT) VENV_BIN := $(VENV)/Scripts endif # Optionally overridden by the user in the `release` target. BUMP_ARGS := # Optionally overridden by the user in the `test` target. TESTS ?= # Optionally overridden by the user/CI, to limit the installation to a specific # subset of development dependencies. ID_EXTRA := dev # If the user selects a specific test pattern to run, set `pytest` to fail fast # and only run tests that match the pattern. # Otherwise, run all tests and enable coverage assertions, since we expect # complete test coverage. ifneq ($(TESTS),) TEST_ARGS := -x -k $(TESTS) $(TEST_ARGS) COV_ARGS := else TEST_ARGS := $(TEST_ARGS) # TODO: Reenable coverage testing # COV_ARGS := --fail-under 100 endif ifneq ($(T),) T := $(T) else T := test/unit endif .PHONY: all all: @echo "Run my targets individually!" $(VENV)/pyvenv.cfg: pyproject.toml # Create our Python 3 virtual environment python3 -m venv $(VENV) $(VENV_BIN)/python -m pip install --upgrade pip $(VENV_BIN)/python -m pip install -e .[$(ID_EXTRA)] .PHONY: dev dev: $(VENV)/pyvenv.cfg .PHONY: run run: $(VENV)/pyvenv.cfg @. $(VENV_BIN)/activate && python -m id $(ARGS) .PHONY: lint lint: $(VENV)/pyvenv.cfg . $(VENV_BIN)/activate && \ ruff format --check $(ALL_PY_SRCS) && \ ruff check $(ALL_PY_SRCS) && \ mypy $(PY_MODULE) && \ bandit -c pyproject.toml -r $(PY_MODULE) && \ interrogate --fail-under 80 -c pyproject.toml $(PY_MODULE) .PHONY: reformat reformat: $(VENV)/pyvenv.cfg . $(VENV_BIN)/activate && \ ruff check --fix $(ALL_PY_SRCS) && \ ruff format $(ALL_PY_SRCS) .PHONY: test test: $(VENV)/pyvenv.cfg . $(VENV_BIN)/activate && \ pytest --cov=$(PY_MODULE) $(T) $(TEST_ARGS) && \ python -m coverage report -m $(COV_ARGS) .PHONY: package package: $(VENV)/pyvenv.cfg . $(VENV_BIN)/activate && \ python3 -m build .PHONY: release release: $(VENV)/pyvenv.cfg @. $(VENV_BIN)/activate && \ NEXT_VERSION=$$(bump $(BUMP_ARGS)) && \ git add $(PY_MODULE)/_version.py && git diff --quiet --exit-code && \ git commit -m "version: v$${NEXT_VERSION}" && \ git tag v$${NEXT_VERSION} && \ echo "RUN ME MANUALLY: git push origin main && git push origin v$${NEXT_VERSION}" .PHONY: check-readme check-readme: # id --help @diff \ <( \ awk '/@begin-id-help@/{f=1;next} /@end-id-help@/{f=0} f' \ < README.md | sed '1d;$$d' \ ) \ <( \ $(MAKE) -s run ARGS="--help" \ ) .PHONY: edit edit: $(EDITOR) $(ALL_PY_SRCS) id-1.5.0/README.md000066400000000000000000000070131472413122300133700ustar00rootroot00000000000000id == ![CI](https://github.com/di/id/workflows/CI/badge.svg) [![PyPI version](https://badge.fury.io/py/id.svg)](https://pypi.org/project/id) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/di/id/badge)](https://api.securityscorecards.dev/projects/github.com/di/id) [![SLSA](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev/) `id` is a Python tool for generating OIDC identities. It can automatically detect and produce OIDC credentials on a number of environments, including GitHub Actions, GitLab pipelines and Google Cloud. ## Installation `id` requires Python 3.8 or newer, and can be installed directly via `pip`: ```console python -m pip install id ``` ## Usage You can run `id` as a Python module via `python -m`: ```console python -m id --help ``` Top-level: ``` usage: id [-h] [-V] [-v] [-d] audience a tool for generating OIDC identities positional arguments: audience the OIDC audience to use optional arguments: -h, --help show this help message and exit -V, --version show program's version number and exit -v, --verbose run with additional debug logging; supply multiple times to increase verbosity (default: 0) -d, --decode decode the OIDC token into JSON (default: False) ``` For Python API usage, there is a single importable function, `detect_credential`: ```pycon >>> from id import detect_credential >>> detect_credential(audience='something') '' ``` This function requires an `audience` parameter, which is used when generating the OIDC token. This should be set to the intended audience for the token. If no supported environment is found, `detect_credential` returns `None`. If a supported environment is found but `detect_credential` fails to retrieve a token, it raises `AmbientCredentialError`. ## Supported environments `id` currently supports ambient credential detection in the following environments: * [GitHub Actions](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) * Google Cloud * [Cloud Run](https://cloud.google.com/run/docs/securing/service-identity) * [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) * [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) * [CircleCI](https://circleci.com/docs/oidc-tokens-with-custom-claims/) ### 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 `id` is licensed under the Apache 2.0 License. ## Contributing See [the contributing docs](https://github.com/di/id/blob/main/CONTRIBUTING.md) for details. ### SLSA Provenance This project emits a SLSA provenance on its release! This enables you to verify the integrity of the downloaded artifacts and ensured that the binary's code really comes from this source code. To do so, please follow the instructions [here](https://github.com/slsa-framework/slsa-github-generator#verify-provenance). id-1.5.0/cloudbuild.yaml000066400000000000000000000005301472413122300151200ustar00rootroot00000000000000steps: # Install dependencies - name: python entrypoint: python args: ["-m", "pip", "install", ".", "--user"] # Generate ambient GCP credentials - name: python entrypoint: python args: ["-m", "id", "throwaway"] env: - "GOOGLE_SERVICE_ACCOUNT_NAME=sigstore-python-test@projectsigstore.iam.gserviceaccount.com" id-1.5.0/id/000077500000000000000000000000001472413122300125045ustar00rootroot00000000000000id-1.5.0/id/__init__.py000066400000000000000000000046401472413122300146210ustar00rootroot00000000000000# 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. """ API for retrieving OIDC tokens. """ from __future__ import annotations import base64 from typing import Callable __version__ = "1.5.0" 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(audience: str) -> str | None: """ 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 ._internal.oidc.ambient import ( detect_buildkite, detect_circleci, detect_gcp, detect_github, detect_gitlab, ) detectors: list[Callable[..., str | None]] = [ detect_github, detect_gcp, detect_buildkite, detect_gitlab, detect_circleci, ] for detector in detectors: credential = detector(audience) if credential is not None: return credential return None def decode_oidc_token(token: str) -> tuple[str, str, str]: # Split the token into its three parts: header, payload, and signature header, payload, signature = token.split(".") # Decode base64-encoded header and payload decoded_header = base64.urlsafe_b64decode(header + "==").decode("utf-8") decoded_payload = base64.urlsafe_b64decode(payload + "==").decode("utf-8") return decoded_header, decoded_payload, signature id-1.5.0/id/__main__.py000066400000000000000000000047221472413122300146030ustar00rootroot00000000000000# 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. """ The `python -m id` entrypoint. """ import argparse import logging import os from . import __version__ logging.basicConfig() logger = logging.getLogger(__name__) # NOTE: We configure the top package logger, rather than the root logger, # to avoid overly verbose logging in third-party code by default. package_logger = logging.getLogger("id") package_logger.setLevel(os.environ.get("ID_LOGLEVEL", "INFO").upper()) def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="id", description="a tool for generating OIDC identities", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument( "-v", "--verbose", action="count", default=0, help="run with additional debug logging; supply multiple times to increase verbosity", ) parser.add_argument( "-d", "--decode", action="store_true", help="decode the OIDC token into JSON", ) parser.add_argument( "audience", type=str, default=os.getenv("ID_OIDC_AUDIENCE"), help="the OIDC audience to use", ) return parser def main() -> None: parser = _parser() args = parser.parse_args() # Configure logging upfront, so that we don't miss anything. if args.verbose >= 1: package_logger.setLevel("DEBUG") if args.verbose >= 2: logging.getLogger().setLevel("DEBUG") logger.debug(f"parsed arguments {args}") from . import decode_oidc_token, detect_credential token = detect_credential(args.audience) if token and args.decode: header, payload, signature = decode_oidc_token(token) print(header) print(payload) else: print(token) if __name__ == "__main__": # pragma: no cover main() id-1.5.0/id/_internal/000077500000000000000000000000001472413122300144575ustar00rootroot00000000000000id-1.5.0/id/_internal/oidc/000077500000000000000000000000001472413122300153755ustar00rootroot00000000000000id-1.5.0/id/_internal/oidc/__init__.py000066400000000000000000000011111472413122300175000ustar00rootroot00000000000000# 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. id-1.5.0/id/_internal/oidc/ambient.py000066400000000000000000000272541472413122300174000ustar00rootroot00000000000000# 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. """ from __future__ import annotations import json import logging import os import re import shutil import subprocess # nosec B404 import requests from ... 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 ) _env_var_regex = re.compile(r"[^A-Z0-9_]|^[^A-Z_]") def detect_github(audience: str) -> str | None: """ 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": audience}, headers={"Authorization": f"bearer {req_token}"}, timeout=30, ) try: resp.raise_for_status() except requests.HTTPError as http_error: raise AmbientCredentialError( f"GitHub: OIDC token request failed (code={resp.status_code}, " f"body={resp.content.decode()!r})" ) from http_error except requests.Timeout: raise AmbientCredentialError("GitHub: OIDC token request timed out") try: body = resp.json() value = body["value"] if not isinstance(value, str): raise ValueError("OIDC token is not a string") except Exception as e: raise AmbientCredentialError("GitHub: malformed or incomplete JSON") from e logger.debug("GitHub: successfully requested OIDC token") return value def detect_gcp(audience: str) -> str | None: """ 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"}, timeout=30, ) try: resp.raise_for_status() except requests.HTTPError as http_error: raise AmbientCredentialError( f"GCP: access token request failed (code={resp.status_code}, " f"body={resp.content.decode()!r})" ) from http_error except requests.Timeout: raise AmbientCredentialError("GCP: access token request timed out") 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": audience, "includeEmail": True}, headers={ "Authorization": f"Bearer {access_token}", }, timeout=30, ) 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}, " f"body={resp.content.decode()!r})" ) from http_error except requests.Timeout: raise AmbientCredentialError("GCP: OIDC token request timed out") 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": audience, "format": "full"}, headers={"Metadata-Flavor": "Google"}, timeout=30, ) try: resp.raise_for_status() except requests.HTTPError as http_error: raise AmbientCredentialError( f"GCP: OIDC token request failed (code={resp.status_code}, " f"body={resp.content.decode()!r})" ) from http_error except requests.Timeout: raise AmbientCredentialError("GCP: OIDC token request timed out") logger.debug("GCP: successfully requested OIDC token") return resp.text def detect_buildkite(audience: str) -> str | None: """ Detect and return a Buildkite ambient OIDC credential. Returns `None` if the context is not a Buildkite environment. Raises if the environment is Buildkite, but no Buildkite agent is found or the agent encounters an error when generating an OIDC token. """ logger.debug("Buildkite: looking for OIDC credentials") if not os.getenv("BUILDKITE"): logger.debug("Buildkite: environment doesn't look like BuildKite; giving up") return None # Check that the Buildkite agent executable exists in the `PATH`. if shutil.which("buildkite-agent") is None: raise AmbientCredentialError( "Buildkite: could not find Buildkite agent in Buildkite environment" ) # Now query the agent for a token. # # NOTE(alex): We're silencing `bandit` here. The reasoning for ignoring each # test are as follows. # # B603: This is complaining about invoking an external executable. However, # there doesn't seem to be any way to do this that satisfies `bandit` so I # think we need to ignore this. # More context at: # https://github.com/PyCQA/bandit/issues/333 # # B607: This is complaining about invoking an external executable without # providing an absolute path (we just refer to whatever `buildkite-agent`) # is in the `PATH`. For a Buildkite agent, there's no guarantee where the # `buildkite-agent` is installed so again, I don't think there's anything # we can do about this. process = subprocess.run( # nosec B603, B607 ["buildkite-agent", "oidc", "request-token", "--audience", audience], capture_output=True, text=True, ) if process.returncode != 0: raise AmbientCredentialError( f"Buildkite: the Buildkite agent encountered an error: {process.stdout}" ) return process.stdout.strip() def detect_gitlab(audience: str) -> str | None: """ 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 def detect_circleci(audience: str) -> str | None: """ Detect and return a CircleCI ambient OIDC credential. Returns `None` if the context is not a CircleCI environment. Raises if the environment is GitHub Actions, but is incorrect or insufficiently permissioned for an OIDC credential. """ logger.debug("CircleCI: looking for OIDC credentials") if not os.getenv("CIRCLECI"): logger.debug("CircleCI: environment doesn't look like CircleCI; giving up") return None # Check that the circleci executable exists in the `PATH`. if shutil.which("circleci") is None: raise AmbientCredentialError("CircleCI: could not find `circleci` in the environment") # See NOTE on `detect_buildkite` for why we silence these warnings. payload = json.dumps({"aud": audience}) process = subprocess.run( # nosec B603, B607 ["circleci", "run", "oidc", "get", "--claims", payload], capture_output=True, text=True, ) if process.returncode != 0: raise AmbientCredentialError( f"CircleCI: the `circleci` tool encountered an error: {process.stdout}" ) return process.stdout.strip() id-1.5.0/pyproject.toml000066400000000000000000000042471472413122300150330ustar00rootroot00000000000000[build-system] requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] name = "id" dynamic = ["version"] description = "A tool for generating OIDC identities" readme = "README.md" license = { file = "LICENSE" } classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Security", "Topic :: Security :: Cryptography", ] dependencies = ["requests"] requires-python = ">=3.8" [project.urls] Homepage = "https://pypi.org/project/id/" Issues = "https://github.com/di/id/issues" Source = "https://github.com/di/id" [tool.flit.sdist] include = ["test/"] [project.optional-dependencies] test = ["pytest", "pytest-cov", "pretend", "coverage[toml]"] lint = [ "bandit", "interrogate", "mypy", # NOTE(ww): ruff is under active development, so we pin conservatively here # and let Dependabot periodically perform this update. "ruff < 0.8.2", "types-requests", ] dev = ["build", "bump >= 1.3.2", "id[test,lint]"] [project.entry-points."pipx.run"] id = "id.__main__:main" [tool.interrogate] # don't enforce documentation coverage for packaging, testing, the virtual # environment, or the CLI (which is documented separately). ignore-semiprivate = true ignore-private = true fail-under = 100 [tool.mypy] allow_redefinition = true check_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_defs = true ignore_missing_imports = true no_implicit_optional = true show_error_codes = true sqlite_cache = true strict_equality = true warn_no_return = true warn_redundant_casts = true warn_return_any = true warn_unreachable = true warn_unused_configs = true warn_unused_ignores = true [tool.bandit] exclude_dirs = ["./test"] [tool.ruff] line-length = 100 [tool.ruff.lint] select = ["I", "E", "F", "W", "UP"] id-1.5.0/test/000077500000000000000000000000001472413122300130675ustar00rootroot00000000000000id-1.5.0/test/unit/000077500000000000000000000000001472413122300140465ustar00rootroot00000000000000id-1.5.0/test/unit/__init__.py000066400000000000000000000011111472413122300161510ustar00rootroot00000000000000# 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. id-1.5.0/test/unit/internal/000077500000000000000000000000001472413122300156625ustar00rootroot00000000000000id-1.5.0/test/unit/internal/__init__.py000066400000000000000000000011111472413122300177650ustar00rootroot00000000000000# 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. id-1.5.0/test/unit/internal/oidc/000077500000000000000000000000001472413122300166005ustar00rootroot00000000000000id-1.5.0/test/unit/internal/oidc/__init__.py000066400000000000000000000011111472413122300207030ustar00rootroot00000000000000# 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. id-1.5.0/test/unit/internal/oidc/test_ambient.py000066400000000000000000000702311472413122300216330ustar00rootroot00000000000000# 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 json import pretend import pytest from requests import HTTPError, Timeout from id import detect_credential from id._internal.oidc import ambient def test_detect_credential_none(monkeypatch): detect_none = pretend.call_recorder(lambda audience: None) monkeypatch.setattr(ambient, "detect_github", detect_none) monkeypatch.setattr(ambient, "detect_gcp", detect_none) monkeypatch.setattr(ambient, "detect_buildkite", detect_none) assert detect_credential("some-audience") is None def test_detect_credential(monkeypatch): detect_github = pretend.call_recorder(lambda audience: "fakejwt") monkeypatch.setattr(ambient, "detect_github", detect_github) assert detect_credential("some-audience") == "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("some-audience") 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("some-audience") 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("some-audience") 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, content=b"something", ) 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, body='something'\)", ): ambient.detect_github("some-audience") assert requests.get.calls == [ pretend.call( "fakeurl", params={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) ] def test_detect_github_request_timeout(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(Timeout)) requests = pretend.stub( get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError, Timeout=Timeout, ) monkeypatch.setattr(ambient, "requests", requests) with pytest.raises( ambient.AmbientCredentialError, match=r"GitHub: OIDC token request timed out", ): ambient.detect_github("some-audience") assert requests.get.calls == [ pretend.call( "fakeurl", params={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) ] def test_detect_github_invalid_json_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.raiser(json.JSONDecodeError)) 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("some-audience") assert requests.get.calls == [ pretend.call( "fakeurl", params={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) ] @pytest.mark.parametrize("payload", [{}, {"notvalue": None}, {"value": None}, {"value": 1234}]) def test_detect_github_bad_payload(monkeypatch, payload): 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: payload)) 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("some-audience") assert requests.get.calls == [ pretend.call( "fakeurl", params={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) ] 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("some-audience") == "fakejwt" assert requests.get.calls == [ pretend.call( "fakeurl", params={"audience": "some-audience"}, headers={"Authorization": "bearer faketoken"}, timeout=30, ) ] 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, content=b"something", ) 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, body='something'\)", ): ambient.detect_gcp("some-audience") 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_request_timeout(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(Timeout)) requests = pretend.stub( get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError, Timeout=Timeout, ) monkeypatch.setattr(ambient, "requests", requests) with pytest.raises( ambient.AmbientCredentialError, match=r"GCP: access token request timed out", ): ambient.detect_gcp("some-audience") 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("some-audience") 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, content=b"something", ) 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, body='something'\)", ): ambient.detect_gcp("some-audience") 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_request_timeout(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(Timeout)) requests = pretend.stub( get=pretend.call_recorder(lambda url, **kw: get_resp), post=pretend.call_recorder(lambda url, **kw: post_resp), HTTPError=HTTPError, Timeout=Timeout, ) monkeypatch.setattr(ambient, "requests", requests) with pytest.raises( ambient.AmbientCredentialError, match=r"GCP: OIDC token request timed out", ): ambient.detect_gcp("some-audience") 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("some-audience") 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("some-audience") == 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("some-audience") 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("some-audience") 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, content=b"something", ) 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, body='something'\)", ): ambient.detect_gcp("some-audience") assert requests.get.calls == [ pretend.call( ambient._GCP_IDENTITY_REQUEST_URL, params={"audience": "some-audience", "format": "full"}, headers={"Metadata-Flavor": "Google"}, timeout=30, ) ] def test_detect_gcp_request_timeout(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(Timeout)) requests = pretend.stub( get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError, Timeout=Timeout, ) monkeypatch.setattr(ambient, "requests", requests) with pytest.raises( ambient.AmbientCredentialError, match=r"GCP: OIDC token request timed out", ): ambient.detect_gcp("some-audience") assert requests.get.calls == [ pretend.call( ambient._GCP_IDENTITY_REQUEST_URL, params={"audience": "some-audience", "format": "full"}, headers={"Metadata-Flavor": "Google"}, timeout=30, ) ] @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("some-audience") == "fakejwt" assert requests.get.calls == [ pretend.call( ambient._GCP_IDENTITY_REQUEST_URL, params={"audience": "some-audience", "format": "full"}, headers={"Metadata-Flavor": "Google"}, timeout=30, ) ] 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"), ] def test_buildkite_no_agent(monkeypatch): monkeypatch.setenv("BUILDKITE", "true") # Mock out the `which` call. We don't expect this to exist in the `PATH` but # just in case someone is running these tests on a Buildkite host... shutil = pretend.stub(which=pretend.call_recorder(lambda bin: None)) monkeypatch.setattr(ambient, "shutil", shutil) with pytest.raises( ambient.AmbientCredentialError, match=r"Buildkite: could not find Buildkite agent in Buildkite environment", ): ambient.detect_buildkite("some-audience") assert shutil.which.calls == [pretend.call("buildkite-agent")] def test_buildkite_agent_error(monkeypatch): monkeypatch.setenv("BUILDKITE", "true") # Mock out the `which` call to show that we have a `buildkite-agent` in our `PATH`. shutil = pretend.stub(which=pretend.call_recorder(lambda bin: "/usr/bin/buildkite-agent")) monkeypatch.setattr(ambient, "shutil", shutil) # Mock out `run` call to emulate getting a non-zero return code from the `buildkite-agent`. resp = pretend.stub( returncode=-1, stdout="mock error message", ) subprocess = pretend.stub(run=pretend.call_recorder(lambda run_args, **kw: resp), PIPE=None) monkeypatch.setattr(ambient, "subprocess", subprocess) with pytest.raises( ambient.AmbientCredentialError, match=r"Buildkite: the Buildkite agent encountered an error: mock error message", ): ambient.detect_buildkite("some-audience") assert shutil.which.calls == [pretend.call("buildkite-agent")] assert subprocess.run.calls == [ pretend.call( ["buildkite-agent", "oidc", "request-token", "--audience", "some-audience"], capture_output=True, text=True, ) ] def test_buildkite(monkeypatch): monkeypatch.setenv("BUILDKITE", "true") # Mock out the `which` call to show that we have a `buildkite-agent` in our `PATH`. shutil = pretend.stub(which=pretend.call_recorder(lambda bin: "/usr/bin/buildkite-agent")) monkeypatch.setattr(ambient, "shutil", shutil) # Mock out `run` call to emulate getting a successful return code from the `buildkite-agent`. resp = pretend.stub( returncode=0, stdout="fakejwt", ) subprocess = pretend.stub(run=pretend.call_recorder(lambda run_args, **kw: resp), PIPE=None) monkeypatch.setattr(ambient, "subprocess", subprocess) assert ambient.detect_buildkite("some-audience") == "fakejwt" assert shutil.which.calls == [pretend.call("buildkite-agent")] assert subprocess.run.calls == [ pretend.call( ["buildkite-agent", "oidc", "request-token", "--audience", "some-audience"], capture_output=True, 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"), ] def test_circleci_bad_env(monkeypatch): monkeypatch.delenv("CIRCLECI", False) logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) monkeypatch.setattr(ambient, "logger", logger) assert ambient.detect_circleci("some-audience") is None assert logger.debug.calls == [ pretend.call("CircleCI: looking for OIDC credentials"), pretend.call("CircleCI: environment doesn't look like CircleCI; giving up"), ] def test_circleci_no_circleci_cli(monkeypatch): monkeypatch.setenv("CIRCLECI", "true") # Mock out the `which` call. We don't expect this to exist in the `PATH` but # just in case someone is running these tests on a Buildkite host... shutil = pretend.stub(which=pretend.call_recorder(lambda bin: None)) monkeypatch.setattr(ambient, "shutil", shutil) with pytest.raises( ambient.AmbientCredentialError, match=r"CircleCI: could not find `circleci` in the environment", ): ambient.detect_circleci("some-audience") assert shutil.which.calls == [pretend.call("circleci")] def test_circleci_circlecli_error(monkeypatch): monkeypatch.setenv("CIRCLECI", "true") # Mock out the `which` call to show that we have a `circleci` in our `PATH`. shutil = pretend.stub(which=pretend.call_recorder(lambda bin: "/usr/bin/circleci")) monkeypatch.setattr(ambient, "shutil", shutil) # Mock out `run` call to emulate getting a non-zero return code from the `circleci`. resp = pretend.stub( returncode=-1, stdout="mock error message", ) subprocess = pretend.stub(run=pretend.call_recorder(lambda run_args, **kw: resp), PIPE=None) monkeypatch.setattr(ambient, "subprocess", subprocess) payload = json.dumps({"aud": "some-audience"}) with pytest.raises( ambient.AmbientCredentialError, match=r"CircleCI: the `circleci` tool encountered an error: mock error message", ): ambient.detect_circleci("some-audience") assert shutil.which.calls == [pretend.call("circleci")] assert subprocess.run.calls == [ pretend.call( ["circleci", "run", "oidc", "get", "--claims", payload], capture_output=True, text=True, ) ] def test_circleci(monkeypatch): monkeypatch.setenv("CIRCLECI", "true") # Mock out the `which` call to show that we have a `circleci` in our `PATH`. shutil = pretend.stub(which=pretend.call_recorder(lambda bin: "/usr/bin/circleci")) monkeypatch.setattr(ambient, "shutil", shutil) # Mock out `run` call to emulate getting a successful return code from the `circleci`. resp = pretend.stub( returncode=0, stdout="fakejwt", ) subprocess = pretend.stub(run=pretend.call_recorder(lambda run_args, **kw: resp), PIPE=None) monkeypatch.setattr(ambient, "subprocess", subprocess) payload = json.dumps({"aud": "some-audience"}) assert ambient.detect_circleci("some-audience") == "fakejwt" assert shutil.which.calls == [pretend.call("circleci")] assert subprocess.run.calls == [ pretend.call( ["circleci", "run", "oidc", "get", "--claims", payload], capture_output=True, text=True, ) ]