Skip to content

Commit 127f65f

Browse files
committed
oidc-exchange: warn on reusable workflow
Signed-off-by: William Woodruff <william@trailofbits.com>
1 parent 218af42 commit 127f65f

File tree

2 files changed

+61
-6
lines changed

2 files changed

+61
-6
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ repos:
9595
WPS102,
9696
WPS110,
9797
WPS111,
98+
WPS202,
9899
WPS305,
99100
WPS326,
100101
WPS332,

oidc-exchange.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import json
33
import os
44
import sys
5+
import typing
56
from http import HTTPStatus
67
from pathlib import Path
7-
from typing import NoReturn
88
from urllib.parse import urlparse
99

1010
import id # pylint: disable=redefined-builtin
@@ -90,6 +90,28 @@
9090
See https://docs.pypi.org/trusted-publishers/troubleshooting/ for more help.
9191
"""
9292

93+
_REUSABLE_WORKFLOW_WARNING = """
94+
The claims in this token suggest that the calling workflow is a reusable workflow.
95+
96+
In particular, this action was initiated by:
97+
98+
{job_workflow_ref}
99+
100+
Whereas its parent workflow is:
101+
102+
{workflow_ref}
103+
104+
Reusable workflows are **not currently supported** by PyPI's Trusted Publishing
105+
functionality, and are subject to breakage. Users are **strongly encouraged**
106+
to avoid using reusable workflows for Trusted Publishing until support
107+
becomes official.
108+
109+
For more information, see:
110+
111+
* https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github
112+
* https://github.com/pypa/gh-action-pypi-publish/issues/166
113+
"""
114+
93115
# Rendered if the package index's token response isn't valid JSON.
94116
_SERVER_TOKEN_RESPONSE_MALFORMED_JSON = """
95117
Token request failed: the index produced an unexpected
@@ -110,7 +132,7 @@
110132
""" # noqa: S105; not a password
111133

112134

113-
def die(msg: str) -> NoReturn:
135+
def die(msg: str) -> typing.NoReturn:
114136
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io:
115137
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io)
116138

@@ -122,6 +144,14 @@ def die(msg: str) -> NoReturn:
122144
sys.exit(1)
123145

124146

147+
def warn(msg: str) -> None:
148+
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io:
149+
print(msg, file=io)
150+
151+
msg = msg.replace('\n', '%0A')
152+
print(f'::warning::Potential workflow misconfiguration: {msg}', file=sys.stderr)
153+
154+
125155
def debug(msg: str):
126156
print(f'::debug::{msg.title()}', file=sys.stderr)
127157

@@ -161,13 +191,15 @@ def assert_successful_audience_call(resp: requests.Response, domain: str):
161191
)
162192

163193

164-
def render_claims(token: str) -> str:
194+
def extract_claims(token: str) -> dict[str, typing.Any]:
165195
_, payload, _ = token.split('.', 2)
166196

167197
# urlsafe_b64decode needs padding; JWT payloads don't contain any.
168198
payload += '=' * (4 - (len(payload) % 4))
169-
claims = json.loads(base64.urlsafe_b64decode(payload))
199+
return json.loads(base64.urlsafe_b64decode(payload))
200+
170201

202+
def render_claims(claims: dict[str, typing.Any]) -> str:
171203
def _get(name: str) -> str: # noqa: WPS430
172204
return claims.get(name, 'MISSING')
173205

@@ -182,6 +214,21 @@ def _get(name: str) -> str: # noqa: WPS430
182214
)
183215

184216

217+
def warn_on_reusable_workflow(claims: dict[str, typing.Any]) -> None:
218+
# A reusable workflow is identified by having different values
219+
# for its workflow_ref (the initiating workflow) and job_workflow_ref
220+
# (the reusable workflow).
221+
if claims.get('workflow_ref') == claims.get('job_workflow_ref'):
222+
return
223+
224+
warn(
225+
_REUSABLE_WORKFLOW_WARNING.format(
226+
job_workflow_ref=claims.get('job_workflow_ref'),
227+
workflow_ref=claims.get('workflow_ref'),
228+
),
229+
)
230+
231+
185232
def event_is_third_party_pr() -> bool:
186233
# Non-`pull_request` events cannot be from third-party PRs.
187234
if os.getenv('GITHUB_EVENT_NAME') != 'pull_request':
@@ -223,12 +270,19 @@ def event_is_third_party_pr() -> bool:
223270
oidc_token = id.detect_credential(audience=oidc_audience)
224271
except id.IdentityError as identity_error:
225272
cause_msg_tmpl = (
226-
_TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE if event_is_third_party_pr()
273+
_TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE
274+
if event_is_third_party_pr()
227275
else _TOKEN_RETRIEVAL_FAILED_MESSAGE
228276
)
229277
for_cause_msg = cause_msg_tmpl.format(identity_error=identity_error)
230278
die(for_cause_msg)
231279

280+
281+
# Perform a non-fatal check to see if we're running on a reusable
282+
# workflow, and emit a warning if so.
283+
oidc_claims = extract_claims(oidc_token)
284+
warn_on_reusable_workflow(oidc_claims)
285+
232286
# Now we can do the actual token exchange.
233287
mint_token_resp = requests.post(
234288
token_exchange_url,
@@ -255,7 +309,7 @@ def event_is_third_party_pr() -> bool:
255309
for error in mint_token_payload['errors']
256310
)
257311

258-
rendered_claims = render_claims(oidc_token)
312+
rendered_claims = render_claims(oidc_claims)
259313

260314
die(
261315
_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE.format(

0 commit comments

Comments
 (0)