Skip to content

Commit d4f860a

Browse files
Ebonsignoriheiskr
andauthored
Test: guard against DOCS_BOT_PAT_BASE in pull_request local actions (#62393)
Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
1 parent 5a17392 commit d4f860a

1 file changed

Lines changed: 42 additions & 0 deletions

File tree

src/workflows/tests/actions-workflows.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface WorkflowJob {
3636

3737
interface 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

Comments
 (0)