diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9cdbb90 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 + +updates: + - package-ecosystem: pip + directory: "/install" + schedule: + interval: daily + open-pull-requests-limit: 99 + allow: + - dependency-type: direct + - dependency-type: indirect + rebase-strategy: "disabled" + + - package-ecosystem: pip + directory: / + schedule: + interval: daily + + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + open-pull-requests-limit: 99 + rebase-strategy: "disabled" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2ea9c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: 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.7", os: "ubuntu-latest" } + - { py: "3.8", os: "ubuntu-latest" } + - { py: "3.9", os: "ubuntu-latest" } + - { py: "3.10", os: "ubuntu-latest" } + - { py: "3.11", 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.11", os: "windows-latest" } + - { py: "3.11", os: "macos-latest" } + runs-on: ${{ matrix.conf.os }} + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + + - uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 + 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) }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ffc3b3f --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,74 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + + # NOTE: We intentionally lint against our minimum supported Python. + - uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 + with: + python-version: "3.7" + 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@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + + # NOTE: We intentionally check `--help` rendering against our minimum Python, + # since it changes slightly between Python versions. + - uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 + with: + python-version: "3.7" + 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@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + # 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) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a1254c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + release: + types: + - published + +permissions: # added using https://github.com/step-security/secure-workflows + contents: read + +jobs: + build: + name: Build and sign artifacts + runs-on: ubuntu-latest + permissions: + id-token: write + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + + - uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 + with: + python-version: "3.x" + cache: "pip" + cache-dependency-path: pyproject.toml + + - name: deps + run: python -m pip install -U build sigstore + + - name: build + run: python -m build + + - name: sign + run: | + mkdir -p signing-artifacts + + # Sign using the ambient OIDC identity + for dist in dist/*; do + dist_base="$(basename "${dist}")" + + # NOTE: signing artifacts currently go in a separate directory, + # to avoid confusing the package uploader (which otherwise tries + # to upload them to PyPI and fails). Future versions of twine + # and the gh-action-pypi-publish action should support these artifacts. + python -m sigstore sign "${dist}" \ + --output-signature signing-artifacts/"${dist_base}.sig" \ + --output-certificate signing-artifacts/"${dist_base}.crt" + + done + + - 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@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + with: + name: built-packages + path: ./dist/ + if-no-files-found: warn + + - name: Upload signing-artifacts + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + with: + name: signing-artifacts + path: signing-artifacts/ + 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@v1.2.1 + with: + attestation-name: provenance-sigstore-${{ github.event.release.tag_name }}.intoto.jsonl + base64-subjects: "${{ needs.build.outputs.hashes }}" + compile-generator: true # Workaround for https://github.com/slsa-framework/slsa-github-generator/issues/1163 + upload-assets: true + + release-pypi: + needs: [build, generate-provenance] + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Download artifacts directories # goes to current working directory + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 + + - name: publish + uses: pypa/gh-action-pypi-publish@c7f29f7adef1a245bd91520e94867e5c6eedddcc + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + packages_dir: built-packages/ + + 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@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 + + - 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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + # signing-artifacts/ contains the signatures and certificates. + files: | + built-packages/* + signing-artifacts/* diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml new file mode 100644 index 0000000..235ce03 --- /dev/null +++ b/.github/workflows/scorecards-analysis.yml @@ -0,0 +1,57 @@ +name: 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 ] + +# Declare default permissions as read only. +permissions: read-all + +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@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@e38b1902ae4f44df626f11ba0734b14fb91f8f86 # v2.1.2 + 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@6673cd052c4cd6fcf4b4e6e60ea986c889389535 # 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@515828d97454b8354517688ddc5b48402b723750 # v1.0.26 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbed0b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +env/ +pip-wheel-metadata/ +*.egg-info/ +__pycache__/ +.coverage* +html/ +dist/ +.python-version +build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..efad111 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# 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] + +* Initial split from https://github.com/sigstore/sigstore-python + + +[Unreleased]: https://github.com/di/sigstore-python/compare/v1.0.0...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e1775ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,136 @@ +Contributing 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.7 +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. diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt new file mode 100644 index 0000000..5f2c003 --- /dev/null +++ b/COPYRIGHT.txt @@ -0,0 +1,13 @@ +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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aa45ecb --- /dev/null +++ b/Makefile @@ -0,0 +1,114 @@ +SHELL := /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 && \ + black --check $(ALL_PY_SRCS) && \ + isort --check $(ALL_PY_SRCS) && \ + ruff $(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 --fix $(ALL_PY_SRCS) && \ + black $(ALL_PY_SRCS) && \ + isort $(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) diff --git a/README.md b/README.md index e69de29..0e9fc79 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,82 @@ +id +== + + +![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 on an number of environments, including GitHub Actions +and Google Cloud. + +## Installation + +`id` requires Python 3.7 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] 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) +``` + + +For Python API usage, there is a single importable function, `detect_credential`: + +>>> 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. + +## 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 + +## 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#verification-of-provenance). diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..46b76a0 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,12 @@ +steps: + # Install dependencies + - name: python + entrypoint: python + args: ["-m", "pip", "install", ".", "--user"] + + # Generate ambient GCP credentials + - name: python + entrypoint: python + args: ["-m", "id", "--audience", "throwaway"] + env: + - "GOOGLE_SERVICE_ACCOUNT_NAME=sigstore-python-test@projectsigstore.iam.gserviceaccount.com" diff --git a/id/__init__.py b/id/__init__.py new file mode 100644 index 0000000..afe63ac --- /dev/null +++ b/id/__init__.py @@ -0,0 +1,67 @@ +# 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 + +from typing import Callable, List, Optional + +__version__ = "1.0.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) -> 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 ._internal.oidc.ambient import detect_gcp, detect_github + + detectors: List[Callable[..., Optional[str]]] = [detect_github, detect_gcp] + for detector in detectors: + credential = detector(audience) + if credential is not None: + return credential + return None diff --git a/id/__main__.py b/id/__main__.py new file mode 100644 index 0000000..c9fe5ab --- /dev/null +++ b/id/__main__.py @@ -0,0 +1,77 @@ +# 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( + "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 detect_credential + + print(detect_credential(args.audience)) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/id/_internal/oidc/__init__.py b/id/_internal/oidc/__init__.py new file mode 100644 index 0000000..88cb71f --- /dev/null +++ b/id/_internal/oidc/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/id/_internal/oidc/ambient.py b/id/_internal/oidc/ambient.py new file mode 100644 index 0000000..c52c204 --- /dev/null +++ b/id/_internal/oidc/ambient.py @@ -0,0 +1,190 @@ +# 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. +""" + +import logging +import os +from typing import Optional + +import requests +from pydantic import BaseModel, StrictStr + +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 + + +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(audience: str) -> 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": 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: + body = resp.json() + payload = _GitHubTokenPayload(**body) + except Exception as e: + raise AmbientCredentialError("GitHub: malformed or incomplete JSON") from e + + logger.debug("GCP: successfully requested OIDC token") + return payload.value + + +def detect_gcp(audience: str) -> 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": 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": 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f8a83e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,106 @@ +[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" } +authors = [ + { name = "Sigstore Authors", email = "sigstore-dev@googlegroups.com" } +] +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Security", + "Topic :: Security :: Cryptography", +] +dependencies = [ + "pydantic", + "requests", +] +requires-python = ">=3.7" + +[project.urls] +Homepage = "https://pypi.org/project/id/" +Issues = "https://github.com/di/id/issues" +Source = "https://github.com/di/id" + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "pretend", + "coverage[toml]", +] +lint = [ + "bandit", + "black", + "isort", + "interrogate", + "mypy", + # NOTE(ww): ruff is under active development, so we pin conservatively here + # and let Dependabot periodically perform this update. + "ruff < 0.0.224", + "types-requests", + # Needed for protocol typing in 3.7; remove when our minimum Python is 3.8. + "typing-extensions; python_version < '3.8'", +] +dev = [ + "build", + "bump >= 1.3.2", + "id[test,lint]", +] + +[tool.isort] +multi_line_output = 3 +known_first_party = "id" +include_trailing_comma = true + +[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 +# Ignore nested classes for docstring coverage because we use them primarily +# for pydantic model configuration. +ignore-nested-classes = 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 +plugins = ["pydantic.mypy"] + +[tool.bandit] +exclude_dirs = ["./test"] + +[tool.ruff] +line-length = 100 +# TODO: Enable "UP" here once Pydantic allows us to: +# See: https://github.com/pydantic/pydantic/issues/4146 +select = ["E", "F", "W"] +target-version = "py37" diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..88cb71f --- /dev/null +++ b/test/unit/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/test/unit/internal/__init__.py b/test/unit/internal/__init__.py new file mode 100644 index 0000000..88cb71f --- /dev/null +++ b/test/unit/internal/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/test/unit/internal/oidc/__init__.py b/test/unit/internal/oidc/__init__.py new file mode 100644 index 0000000..88cb71f --- /dev/null +++ b/test/unit/internal/oidc/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/test/unit/internal/oidc/test_ambient.py b/test/unit/internal/oidc/test_ambient.py new file mode 100644 index 0000000..dafe989 --- /dev/null +++ b/test/unit/internal/oidc/test_ambient.py @@ -0,0 +1,417 @@ +# 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 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) + 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) + 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("some-audience") + assert requests.get.calls == [ + pretend.call( + "fakeurl", + params={"audience": "some-audience"}, + 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("some-audience") + assert requests.get.calls == [ + pretend.call( + "fakeurl", + params={"audience": "some-audience"}, + 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("some-audience") == "fakejwt" + assert requests.get.calls == [ + pretend.call( + "fakeurl", + params={"audience": "some-audience"}, + 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("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 + ) + 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("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) + 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("some-audience") + assert requests.get.calls == [ + pretend.call( + ambient._GCP_IDENTITY_REQUEST_URL, + params={"audience": "some-audience", "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("some-audience") == "fakejwt" + assert requests.get.calls == [ + pretend.call( + ambient._GCP_IDENTITY_REQUEST_URL, + params={"audience": "some-audience", "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"), + ]