@@ -36,6 +36,7 @@ interface WorkflowJob {
3636
3737interface WorkflowStep {
3838 uses ?: string
39+ with ?: Record < string , unknown >
3940 [ key : string ] : unknown
4041}
4142
@@ -217,4 +218,45 @@ describe('GitHub Actions workflows', () => {
217218 }
218219 } ,
219220 )
221+
222+ // A long-lived shared PAT (DOCS_BOT_PAT_BASE) must never be handed to a
223+ // local composite action (`uses: ./...`) inside a `pull_request_target`
224+ // workflow. That trigger runs with full repository secrets even for PRs
225+ // opened from forks by anonymous outside contributors, and the local action
226+ // lives in the checked-out PR workspace, so a malicious fork PR could rewrite
227+ // it to exfiltrate the token. Such jobs should generate a short-lived, scoped
228+ // GitHub App token instead.
229+ //
230+ // NOTE: this intentionally does NOT cover plain `pull_request`. That trigger
231+ // does not expose secrets to fork PRs — only to same-repo branch PRs from
232+ // contributors who already have write access — and passing the PAT to local
233+ // actions there (e.g. get-docs-early-access) is a longstanding, accepted
234+ // pattern across many workflows. See #62343.
235+ const pullRequestTargetWorkflows = workflows . filter ( ( { data } ) => {
236+ const on = ( data . on || { } ) as Record < string , unknown >
237+ // Use key presence, not truthiness: a trigger with no nested value parses
238+ // to null, which a truthy check would skip.
239+ return 'pull_request_target' in on
240+ } )
241+
242+ test . each ( pullRequestTargetWorkflows ) (
243+ 'does not pass DOCS_BOT_PAT_BASE to a local composite action in $filename' ,
244+ ( { filename, data } ) => {
245+ for ( const [ name , job ] of Object . entries ( data . jobs ) ) {
246+ for ( const step of job . steps || [ ] ) {
247+ const usesLocalAction = typeof step . uses === 'string' && step . uses . startsWith ( './' )
248+ if ( ! usesLocalAction || ! step . with ) continue
249+ const passesPat = Object . values ( step . with ) . some (
250+ ( value ) => typeof value === 'string' && value . includes ( 'DOCS_BOT_PAT_BASE' ) ,
251+ )
252+ if ( passesPat ) {
253+ throw new Error (
254+ `Job ${ filename } # ${ name } passes DOCS_BOT_PAT_BASE into local action ${ step . uses } ; ` +
255+ `pull_request_target workflows must use a scoped GitHub App token instead` ,
256+ )
257+ }
258+ }
259+ }
260+ } ,
261+ )
220262} )
0 commit comments