diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..aba4b4a --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,65 @@ +[mypy] +python_version = 3.11 +color_output = true +error_summary = true +files = + ., + # src/, + # tests/, + +check_untyped_defs = true + +disallow_any_explicit = true +disallow_any_expr = true +disallow_any_decorated = true +disallow_any_generics = true +disallow_any_unimported = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true + +enable_error_code = + ignore-without-code + +explicit_package_bases = true + +extra_checks = true + +follow_imports = normal + +ignore_missing_imports = false + +local_partial_types = true + +mypy_path = ${MYPY_CONFIG_FILE_DIR}:${MYPY_CONFIG_FILE_DIR}/bin:${MYPY_CONFIG_FILE_DIR}/src:${MYPY_CONFIG_FILE_DIR}/_type_stubs + +namespace_packages = true + +no_implicit_reexport = true + +pretty = true + +show_column_numbers = true +show_error_code_links = true +show_error_codes = true +show_error_context = true +show_error_end = true + +# `strict` will pick up any future strictness-related settings: +strict = true +strict_equality = true +strict_optional = true + +warn_no_return = true +warn_redundant_casts = true +warn_return_any = true +warn_unused_configs = true +warn_unused_ignores = true + +[mypy-tests.*] +# crashes with some decorators like `@functools.cache` and `@pytest.mark.parametrize`: +disallow_any_expr = false +# fails on `@hypothesis.given()`: +disallow_any_decorated = false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 467a23e..9d09214 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -115,6 +115,70 @@ repos: - flake8-pytest-style ~= 2.1.0 - wemake-python-styleguide ~= 1.0.0 +- repo: https://github.com/pre-commit/mirrors-mypy.git + rev: v1.16.1 + hooks: + - id: mypy + alias: mypy-py313 + name: MyPy, for Python 3.13 + additional_dependencies: + - id # used by `oidc-exchange.py` + - lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report` + - packaging # used by `print-pkg-names.py` + - pypi-attestations # used by `attestations.py` + - sigstore # used by `attestations.py` + - types-requests # used by `oidc-exchange.py` + args: + - --python-version=3.13 + - --any-exprs-report=.tox/.tmp/.test-results/mypy--py-3.13 + - --cobertura-xml-report=.tox/.tmp/.test-results/mypy--py-3.13 + - --html-report=.tox/.tmp/.test-results/mypy--py-3.13 + - --linecount-report=.tox/.tmp/.test-results/mypy--py-3.13 + - --linecoverage-report=.tox/.tmp/.test-results/mypy--py-3.13 + - --lineprecision-report=.tox/.tmp/.test-results/mypy--py-3.13 + - --txt-report=.tox/.tmp/.test-results/mypy--py-3.13 + pass_filenames: false + - id: mypy + alias: mypy-py312 + name: MyPy, for Python 3.12 + additional_dependencies: + - id # used by `oidc-exchange.py` + - lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report` + - packaging # used by `print-pkg-names.py` + - pypi-attestations # used by `attestations.py` + - sigstore # used by `attestations.py` + - types-requests # used by `oidc-exchange.py` + args: + - --python-version=3.12 + - --any-exprs-report=.tox/.tmp/.test-results/mypy--py-3.12 + - --cobertura-xml-report=.tox/.tmp/.test-results/mypy--py-3.12 + - --html-report=.tox/.tmp/.test-results/mypy--py-3.12 + - --linecount-report=.tox/.tmp/.test-results/mypy--py-3.12 + - --linecoverage-report=.tox/.tmp/.test-results/mypy--py-3.12 + - --lineprecision-report=.tox/.tmp/.test-results/mypy--py-3.12 + - --txt-report=.tox/.tmp/.test-results/mypy--py-3.12 + pass_filenames: false + - id: mypy + alias: mypy-py311 + name: MyPy, for Python 3.11 + additional_dependencies: + - id # used by `oidc-exchange.py` + - lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report` + - packaging # used by `print-pkg-names.py` + - pypi-attestations # used by `attestations.py` + - sigstore # used by `attestations.py` + - types-requests # used by `oidc-exchange.py` + args: + - --python-version=3.11 + - --any-exprs-report=.tox/.tmp/.test-results/mypy--py-3.11 + - --cobertura-xml-report=.tox/.tmp/.test-results/mypy--py-3.11 + - --html-report=.tox/.tmp/.test-results/mypy--py-3.11 + - --linecount-report=.tox/.tmp/.test-results/mypy--py-3.11 + - --linecoverage-report=.tox/.tmp/.test-results/mypy--py-3.11 + - --lineprecision-report=.tox/.tmp/.test-results/mypy--py-3.11 + - --txt-report=.tox/.tmp/.test-results/mypy--py-3.11 + pass_filenames: false + - repo: https://github.com/PyCQA/pylint.git rev: v3.3.4 hooks: diff --git a/_type_stubs/id.pyi b/_type_stubs/id.pyi new file mode 100644 index 0000000..50e8c29 --- /dev/null +++ b/_type_stubs/id.pyi @@ -0,0 +1,3 @@ +class IdentityError(Exception): ... + +def detect_credential(audience: str) -> str | None: ... diff --git a/attestations.py b/attestations.py index 41f2752..bbae394 100644 --- a/attestations.py +++ b/attestations.py @@ -13,7 +13,7 @@ sigstore_logger.setLevel(logging.DEBUG) sigstore_logger.addHandler(logging.StreamHandler()) -_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY')) +_GITHUB_STEP_SUMMARY = Path(os.environ['GITHUB_STEP_SUMMARY']) # The top-level error message that gets rendered. # This message wraps one of the other templates/messages defined below. @@ -122,6 +122,8 @@ def get_identity_token() -> IdentityToken: # from the environment or if the token is malformed. # NOTE: audience is always sigstore. oidc_token = detect_credential() + if oidc_token is None: + raise IdentityError('Attempted to discover OIDC in broken environment') return IdentityToken(oidc_token) diff --git a/oidc-exchange.py b/oidc-exchange.py index 31a68a5..a1e048e 100644 --- a/oidc-exchange.py +++ b/oidc-exchange.py @@ -10,7 +10,7 @@ import id # pylint: disable=redefined-builtin import requests -_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY')) +_GITHUB_STEP_SUMMARY = Path(os.environ['GITHUB_STEP_SUMMARY']) # The top-level error message that gets rendered. # This message wraps one of the other templates/messages defined below. @@ -135,6 +135,50 @@ """ # noqa: S105; not a password +class TrustedPublishingClaims(t.TypedDict): + sub: str + repository: str + repository_owner: str + repository_owner_id: str + workflow_ref: str + job_workflow_ref: str + ref: str + environment: str + + +class PullRequestRepoGitHubEventObject(t.TypedDict): + fork: bool + + +class PullRequestHeadGitHubEventObject(t.TypedDict): + repo: PullRequestRepoGitHubEventObject + + +class PullRequestGitHubEventObject(t.TypedDict): + head: PullRequestHeadGitHubEventObject + + +class ThirdPartyPullRequestGitHubEvent(t.TypedDict): + pull_request: PullRequestGitHubEventObject + + +class TrustedPublishingAudience(t.TypedDict): + audience: str + + +class TrustedPublishingTokenRetrievalError(t.TypedDict): + code: str + description: str + + +class TrustedPublishingToken(t.TypedDict): + message: str + errors: list[TrustedPublishingTokenRetrievalError] + token: str + success: bool + expires: int + + def die(msg: str) -> t.NoReturn: with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io: print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io) @@ -155,7 +199,7 @@ def warn(msg: str) -> None: print(f'::warning::Potential workflow misconfiguration: {msg}', file=sys.stderr) -def debug(msg: str): +def debug(msg: str) -> None: print(f'::debug::{msg.title()}', file=sys.stderr) @@ -166,7 +210,7 @@ def get_normalized_input(name: str) -> str | None: return os.getenv(name.replace('-', '_')) -def assert_successful_audience_call(resp: requests.Response, domain: str): +def assert_successful_audience_call(resp: requests.Response, domain: str) -> None: if resp.ok: return @@ -194,17 +238,21 @@ def assert_successful_audience_call(resp: requests.Response, domain: str): ) -def extract_claims(token: str) -> dict[str, object]: +def extract_claims(token: str) -> TrustedPublishingClaims: _, payload, _ = token.split('.', 2) # urlsafe_b64decode needs padding; JWT payloads don't contain any. payload += '=' * (4 - (len(payload) % 4)) - return json.loads(base64.urlsafe_b64decode(payload)) + + claims: TrustedPublishingClaims = json.loads( + base64.urlsafe_b64decode(payload), + ) + return claims -def render_claims(claims: dict[str, object]) -> str: +def render_claims(claims: TrustedPublishingClaims) -> str: def _get(name: str) -> str: # noqa: WPS430 - return claims.get(name, 'MISSING') + return str(claims.get(name, 'MISSING')) return _RENDERED_CLAIMS.format( sub=_get('sub'), @@ -218,7 +266,7 @@ def _get(name: str) -> str: # noqa: WPS430 ) -def warn_on_reusable_workflow(claims: dict[str, object]) -> None: +def warn_on_reusable_workflow(claims: TrustedPublishingClaims) -> None: # A reusable workflow is identified by having different values # for its workflow_ref (the initiating workflow) and job_workflow_ref # (the reusable workflow). @@ -228,7 +276,11 @@ def warn_on_reusable_workflow(claims: dict[str, object]) -> None: if workflow_ref == job_workflow_ref: return - warn(_REUSABLE_WORKFLOW_WARNING.format_map(locals())) + warn( + _REUSABLE_WORKFLOW_WARNING.format( + workflow_ref=workflow_ref, job_workflow_ref=job_workflow_ref, + ), + ) def event_is_third_party_pr() -> bool: @@ -243,7 +295,9 @@ def event_is_third_party_pr() -> bool: return False try: - event = json.loads(Path(event_path).read_bytes()) + event: ThirdPartyPullRequestGitHubEvent = json.loads( + Path(event_path).read_bytes(), + ) except json.JSONDecodeError: debug('unexpected: GITHUB_EVENT_PATH does not contain valid JSON') return False @@ -254,8 +308,17 @@ def event_is_third_party_pr() -> bool: return False +def _detect_credential(audience: str, /) -> str: + token = id.detect_credential(audience=audience) + if token is None: + raise id.IdentityError( + 'Attempted to discover OIDC in broken environment', + ) + return token + + repository_url = get_normalized_input('repository-url') -repository_domain = urlparse(repository_url).netloc +repository_domain = str(urlparse(repository_url).netloc) token_exchange_url = f'https://{repository_domain}/_/oidc/mint-token' # Indices are expected to support `https://{domain}/_/oidc/audience`, @@ -264,12 +327,15 @@ def event_is_third_party_pr() -> bool: audience_resp = requests.get(audience_url, timeout=5) # S113 wants a timeout assert_successful_audience_call(audience_resp, repository_domain) -oidc_audience = audience_resp.json()['audience'] + +oidc_audience_resp: TrustedPublishingAudience = audience_resp.json() +oidc_audience = oidc_audience_resp['audience'] debug(f'selected trusted publishing exchange endpoint: {token_exchange_url}') + try: - oidc_token = id.detect_credential(audience=oidc_audience) + oidc_token = _detect_credential(oidc_audience) except id.IdentityError as identity_error: cause_msg_tmpl = ( _TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE @@ -285,15 +351,17 @@ def event_is_third_party_pr() -> bool: oidc_claims = extract_claims(oidc_token) warn_on_reusable_workflow(oidc_claims) +oidc_token_payload: dict[str, str] = {'token': oidc_token} # Now we can do the actual token exchange. mint_token_resp = requests.post( token_exchange_url, - json={'token': oidc_token}, + json=oidc_token_payload, timeout=5, # S113 wants a timeout ) + try: - mint_token_payload = mint_token_resp.json() + mint_token_payload: TrustedPublishingToken = mint_token_resp.json() except requests.JSONDecodeError: # Token exchange failure normally produces a JSON error response, but # we might have hit a server error instead. diff --git a/print-pkg-names.py b/print-pkg-names.py index e2eeb82..5632377 100644 --- a/print-pkg-names.py +++ b/print-pkg-names.py @@ -4,7 +4,7 @@ from packaging import utils -def debug(msg: str): +def debug(msg: str) -> None: print(f'::debug::{msg.title()}', file=sys.stderr)