From 5196cc7017b22fdef383c87fe5eb744e306f8b97 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 23 Feb 2023 17:51:04 +0000 Subject: [PATCH] Initial commit --- .github/dependabot.yml | 24 ++ .github/workflows/ci.yml | 57 +++ .github/workflows/lint.yml | 74 ++++ .github/workflows/release.yml | 125 +++++++ .github/workflows/scorecards-analysis.yml | 57 +++ .gitignore | 9 + CHANGELOG.md | 12 + CONTRIBUTING.md | 136 +++++++ COPYRIGHT.txt | 13 + LICENSE | 202 +++++++++++ Makefile | 114 ++++++ README.md | 82 +++++ cloudbuild.yaml | 12 + id/__init__.py | 67 ++++ id/__main__.py | 77 ++++ id/_internal/oidc/__init__.py | 13 + id/_internal/oidc/ambient.py | 190 ++++++++++ pyproject.toml | 106 ++++++ test/unit/__init__.py | 13 + test/unit/internal/__init__.py | 13 + test/unit/internal/oidc/__init__.py | 13 + test/unit/internal/oidc/test_ambient.py | 417 ++++++++++++++++++++++ 22 files changed, 1826 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/scorecards-analysis.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 COPYRIGHT.txt create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 cloudbuild.yaml create mode 100644 id/__init__.py create mode 100644 id/__main__.py create mode 100644 id/_internal/oidc/__init__.py create mode 100644 id/_internal/oidc/ambient.py create mode 100644 pyproject.toml create mode 100644 test/unit/__init__.py create mode 100644 test/unit/internal/__init__.py create mode 100644 test/unit/internal/oidc/__init__.py create mode 100644 test/unit/internal/oidc/test_ambient.py 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"), + ]