From 9271967ce2cb3d5d6c6f775dc71f356f19422ace Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Mon, 18 Mar 2024 16:42:52 +0200 Subject: [PATCH] Don't require a GitHub token (#134) The new extremely-dangerous-public-oidc-beacon current-token branch allows us to fetch the JWT without a GitHub token. This makes local tests much nicer. Changes: * git clone the beacon repo to get the token * Do this in a loop until token is valid * Cache the token so we only do this once in a test run * Remove all mention of github token in arguments, comments and docs Some additional notes: * retry times are tweaked from the values upstream (and special values are set for the interactive case) * The caching could be smarter (could return cached value only if it's valid) but maybe this simple thing works? Signed-off-by: Jussi Kukkonen --- README.md | 18 +++----- action.py | 4 -- test/conftest.py | 118 ++++++++++++++++------------------------------- 3 files changed, 46 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index ad3eb58..e90e9c7 100644 --- a/README.md +++ b/README.md @@ -84,29 +84,23 @@ $ source env/bin/activate The test suite can be configured with * `--entrypoint=$SIGSTORE_CLIENT` where SIGSTORE_CLIENT is path to a script that implements the [CLI specification](https://github.com/sigstore/sigstore-conformance/blob/main/docs/cli_protocol.md) -* `--identity-token=$GITHUB_TOKEN` where GITHUB_TOKEN is a GitHub token with actions:read - access for public repositories (--identity-token is only required for signing tests) * optional `--staging`: This instructs the test suite to run against Sigstore staging infrastructure +* optional `--skip-signing`: Runs verification tests only * The environment variable `GHA_SIGSTORE_CONFORMANCE_XFAIL` can be used to - set expected results + set expected failures ```sh +(env) $ # run all tests +(env) $ pytest test --entrypoint=$SIGSTORE_CLIENT (env) $ # run verification tests only (env) $ pytest test --entrypoint=$SIGSTORE_CLIENT --skip-signing -(env) $ # run all tests -(env) $ pytest test --entrypoint=$SIGSTORE_CLIENT --identity-token=$GITHUB_TOKEN ``` -Following examples run the included sigstore-python-conformance client script and use the -[`gh` CLI](https://cli.github.com/): +Following example runs the test suite with the included sigstore-python-conformance client script: ```sh -(env) $ # run verification tests only -(env) $ GHA_SIGSTORE_CONFORMANCE_XFAIL="test_verify_with_trust_root test_verify_dsse_bundle_with_trust_root" \ - pytest test --entrypoint=sigstore-python-conformance --skip-signing - (env) $ # run all tests (env) $ GHA_SIGSTORE_CONFORMANCE_XFAIL="test_verify_with_trust_root test_verify_dsse_bundle_with_trust_root" \ - pytest test --entrypoint=sigstore-python-conformance --identity-token=$(gh auth token) + pytest test --entrypoint=sigstore-python-conformance ``` ## Licensing diff --git a/action.py b/action.py index f6038a5..ba072d4 100755 --- a/action.py +++ b/action.py @@ -45,10 +45,6 @@ def _sigstore_conformance(environment: str) -> int: if skip_signing: args.extend(["--skip-signing"]) - gh_token = os.getenv("GHA_SIGSTORE_GITHUB_TOKEN") - if gh_token: - args.extend(["--github-token", gh_token]) - print(f"running sigstore-conformance against Sigstore {environment} infrastructure") _debug(f"running: sigstore-conformance {[str(a) for a in args]}") diff --git a/test/conftest.py b/test/conftest.py index 71cfaa1..ceb6642 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,16 +1,18 @@ +import json import os import shutil +import subprocess import tempfile import time +from base64 import b64decode from collections.abc import Callable from datetime import datetime, timedelta -from io import BytesIO +from functools import lru_cache from pathlib import Path +from tempfile import TemporaryDirectory from typing import TypeVar -from zipfile import ZipFile import pytest -import requests from .client import ( BundleMaterials, @@ -41,10 +43,7 @@ class ConfigError(Exception): def pytest_addoption(parser) -> None: - """ - Add the `--entrypoint`, `--github-token`, and `--skip-signing` flags to - the `pytest` CLI. - """ + """Add `--entrypoint` and `--skip-signing` flags to CLI.""" parser.addoption( "--entrypoint", action="store", @@ -52,12 +51,6 @@ def pytest_addoption(parser) -> None: required=True, type=str, ) - parser.addoption( - "--github-token", - action="store", - help="the GitHub token to supply to the Sigstore client under test", - type=str, - ) parser.addoption( "--skip-signing", action="store_true", @@ -78,9 +71,6 @@ def pytest_runtest_setup(item): def pytest_configure(config): - if not config.getoption("--github-token") and not config.getoption("--skip-signing"): - raise ConfigError("Please specify one of '--github-token' or '--skip-signing'") - config.addinivalue_line("markers", "signing: mark test as requiring signing functionality") config.addinivalue_line("markers", "staging: mark test as supporting testing against staging") @@ -94,75 +84,47 @@ def pytest_internalerror(excrepr, excinfo): @pytest.fixture +@lru_cache def identity_token(pytestconfig) -> str: + # following code is modified from extremely-dangerous-public-oidc-beacon download-token.py. + # Caching can be made smarter (to return the cached token only if it is valid) if token + # starts going invalid during runs + MIN_VALIDITY = timedelta(seconds=20) + MAX_RETRY_TIME = timedelta(minutes=5 if os.getenv("CI") else 1) + RETRY_SLEEP_SECS = 30 if os.getenv("CI") else 5 + GIT_URL = "https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon.git" + + def git_clone(url: str, dir: str) -> None: + base_cmd = ["git", "clone", "--quiet", "--branch", "current-token", "--depth", "1"] + subprocess.run(base_cmd + [url, dir], check=True) + + def is_valid_at(token: str, reference_time: datetime) -> bool: + # split token, b64 decode (with padding), parse as json, validate expiry + payload = token.split(".")[1] + payload += "=" * (4 - len(payload) % 4) + payload_json = json.loads(b64decode(payload)) + + expiry = datetime.fromtimestamp(payload_json["exp"]) + return reference_time < expiry + if pytestconfig.getoption("--skip-signing"): return "" - gh_token = pytestconfig.getoption("--github-token") - session = requests.Session() - headers = { - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "Authorization": f"Bearer {gh_token}", - } - - workflow_time: datetime | None = None - run_id: str - - # We need a token that was generated in the last 5 minutes. Keep checking until we find one. - while workflow_time is None or datetime.now() - workflow_time >= timedelta(minutes=5): - # If there's a lot of traffic in the GitHub Actions cron queue, we might not have a valid - # token to use. In that case, wait for 30 seconds and try again. - if workflow_time is not None: - # FIXME(jl): logging in pytest? - # _log("Couldn't find a recent token, waiting...") - time.sleep(30) - - resp: requests.Response = session.get( - url=_OIDC_BEACON_API_URL + f"/workflows/{_OIDC_BEACON_WORKFLOW_ID}/runs", - headers=headers, - ) - resp.raise_for_status() - - resp_json = resp.json() - workflow_runs = resp_json["workflow_runs"] - if not workflow_runs: - raise OidcTokenError(f"Found no workflow runs: {resp_json}") - - workflow_run = workflow_runs[0] - - # If the job is still running, the token artifact won't have been generated yet. - if workflow_run["status"] != "completed": - continue - - run_id = workflow_run["id"] - workflow_time = datetime.strptime(workflow_run["run_started_at"], "%Y-%m-%dT%H:%M:%SZ") - - resp = session.get( - url=_OIDC_BEACON_API_URL + f"/runs/{run_id}/artifacts", - headers=headers, - ) - resp.raise_for_status() - - resp_json = resp.json() - try: - artifact_id = next(a["id"] for a in resp_json["artifacts"] if a["name"] == "oidc-token") - except StopIteration: - raise OidcTokenError("Artifact 'oidc-token' could not be found") - - # Download the OIDC token artifact and unzip the archive. - resp = session.get( - url=_OIDC_BEACON_API_URL + f"/artifacts/{artifact_id}/zip", - headers=headers, - ) - resp.raise_for_status() + start_time = datetime.now() + while datetime.now() <= start_time + MAX_RETRY_TIME: + with TemporaryDirectory() as tempdir: + git_clone(GIT_URL, tempdir) + + with Path(tempdir, "oidc-token.txt").open() as f: + token = f.read().rstrip() - with ZipFile(BytesIO(resp.content)) as artifact_zip: - artifact_file = artifact_zip.open("oidc-token.txt") + if is_valid_at(token, datetime.now() + MIN_VALIDITY): + return token - # Strip newline. - return artifact_file.read().decode().rstrip() + print(f"Current token expires too early, retrying in {RETRY_SLEEP_SECS} seconds.") + time.sleep(RETRY_SLEEP_SECS) + raise TimeoutError(f"Failed to find a valid token in {MAX_RETRY_TIME}") @pytest.fixture def client(pytestconfig, identity_token):