Compare commits

..

5 Commits

Author SHA1 Message Date
Aiqiao Yan 116db4f9f9 update error wording (#2467) 2026-07-06 19:54:14 +00:00
Aiqiao Yan 946dae4a15 block checking out fork pr for pull_request_target and workflow_run (#2454)
* block checking out fork pr for some events

* address copilot and reviewer feedback

* run prettier formatting

* build

* update urls

* update readme

* update description and url again

* edit url one more time
2026-07-06 19:07:58 +00:00
eric sciple 93cb6efe18 Cleanup actions/checkout@v6 auth style (#2301) 2025-11-13 15:22:10 -06:00
Salman Chishti 08c6903cd8 Prepare v5.0.0 release (#2238) 2025-08-11 13:35:28 +01:00
Salman Chishti 9f265659d3 Update actions checkout to use node 24 (#2226)
* use node 24

* update other parts to node 24

* bump to major version, audit fix, changelog

* update licenses

* update dist

* update major version

* will do separate pr for v5 and will do a minor version for previous changes
2025-08-11 11:52:51 +01:00
20 changed files with 1230 additions and 51 deletions
+2 -2
View File
@@ -24,10 +24,10 @@ jobs:
steps:
- uses: actions/checkout@v4.1.6
- name: Set Node.js 20.x
- name: Set Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
- uses: actions/checkout@v4.1.6
- run: npm ci
- run: npm run build
@@ -11,6 +11,7 @@ on:
type: choice
description: The major version to update
options:
- v5
- v4
- v3
- v2
+4
View File
@@ -1,5 +1,9 @@
# Changelog
## V5.0.0
* Update actions checkout to use node 24 by @salmanmkc in https://github.com/actions/checkout/pull/2226
## V4.3.0
* docs: update README.md by @motss in https://github.com/actions/checkout/pull/1971
* Add internal repos for checking out multiple repositories by @mouismail in https://github.com/actions/checkout/pull/1977
+51 -30
View File
@@ -1,5 +1,9 @@
[![Build and Test](https://github.com/actions/checkout/actions/workflows/test.yml/badge.svg)](https://github.com/actions/checkout/actions/workflows/test.yml)
# Checkout V5
Checkout v5 now supports Node.js 24
# Checkout V4
This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it.
@@ -36,7 +40,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
<!-- start usage -->
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# Repository name with owner. For example, actions/checkout
# Default: ${{ github.repository }}
@@ -144,29 +148,46 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# running from unless specified. Example URLs are https://github.com or
# https://my-ghes-server.example.com
github-server-url: ''
# Required to check out fork pull request code from a workflow triggered by
# `pull_request_target` or `workflow_run`. These workflows run with the base
# repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner
# access; fetching and executing a fork's code in that trusted context commonly
# leads to "pwn request" vulnerabilities. Set to `true` only after reviewing the
# risks at https://gh.io/securely-using-pull_request_target.
# Default: false
allow-unsafe-pr-checkout: ''
```
<!-- end usage -->
# Scenarios
- [Fetch only the root files](#Fetch-only-the-root-files)
- [Fetch only the root files and `.github` and `src` folder](#Fetch-only-the-root-files-and-github-and-src-folder)
- [Fetch only a single file](#Fetch-only-a-single-file)
- [Fetch all history for all tags and branches](#Fetch-all-history-for-all-tags-and-branches)
- [Checkout a different branch](#Checkout-a-different-branch)
- [Checkout HEAD^](#Checkout-HEAD)
- [Checkout multiple repos (side by side)](#Checkout-multiple-repos-side-by-side)
- [Checkout multiple repos (nested)](#Checkout-multiple-repos-nested)
- [Checkout multiple repos (private)](#Checkout-multiple-repos-private)
- [Checkout pull request HEAD commit instead of merge commit](#Checkout-pull-request-HEAD-commit-instead-of-merge-commit)
- [Checkout pull request on closed event](#Checkout-pull-request-on-closed-event)
- [Push a commit using the built-in token](#Push-a-commit-using-the-built-in-token)
- [Push a commit to a PR using the built-in token](#Push-a-commit-to-a-PR-using-the-built-in-token)
- [Checkout V5](#checkout-v5)
- [Checkout V4](#checkout-v4)
- [Note](#note)
- [What's new](#whats-new)
- [Usage](#usage)
- [Scenarios](#scenarios)
- [Fetch only the root files](#fetch-only-the-root-files)
- [Fetch only the root files and `.github` and `src` folder](#fetch-only-the-root-files-and-github-and-src-folder)
- [Fetch only a single file](#fetch-only-a-single-file)
- [Fetch all history for all tags and branches](#fetch-all-history-for-all-tags-and-branches)
- [Checkout a different branch](#checkout-a-different-branch)
- [Checkout HEAD^](#checkout-head)
- [Checkout multiple repos (side by side)](#checkout-multiple-repos-side-by-side)
- [Checkout multiple repos (nested)](#checkout-multiple-repos-nested)
- [Checkout multiple repos (private)](#checkout-multiple-repos-private)
- [Checkout pull request HEAD commit instead of merge commit](#checkout-pull-request-head-commit-instead-of-merge-commit)
- [Checkout pull request on closed event](#checkout-pull-request-on-closed-event)
- [Push a commit using the built-in token](#push-a-commit-using-the-built-in-token)
- [Push a commit to a PR using the built-in token](#push-a-commit-to-a-pr-using-the-built-in-token)
- [Recommended permissions](#recommended-permissions)
- [License](#license)
## Fetch only the root files
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
sparse-checkout: .
```
@@ -174,7 +195,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only the root files and `.github` and `src` folder
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
sparse-checkout: |
.github
@@ -184,7 +205,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only a single file
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
sparse-checkout: |
README.md
@@ -194,7 +215,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch all history for all tags and branches
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
```
@@ -202,7 +223,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout a different branch
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: my-branch
```
@@ -210,7 +231,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout HEAD^
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 2
- run: git checkout HEAD^
@@ -220,12 +241,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
path: main
- name: Checkout tools repo
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: my-org/my-tools
path: my-tools
@@ -236,10 +257,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Checkout tools repo
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: my-org/my-tools
path: my-tools
@@ -250,12 +271,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
path: main
- name: Checkout private tools
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: my-org/my-private-tools
token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT
@@ -268,7 +289,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout pull request HEAD commit instead of merge commit
```yaml
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
```
@@ -284,7 +305,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
```
## Push a commit using the built-in token
@@ -295,7 +316,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- run: |
date > generated.txt
# Note: the following account information will not work on GHES
@@ -317,7 +338,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ github.head_ref }}
- run: |
+291 -1
View File
@@ -675,6 +675,283 @@ describe('git-auth-helper tests', () => {
expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
})
const removeAuth_removesV6StyleCredentials =
'removeAuth removes v6 style credentials'
it(removeAuth_removesV6StyleCredentials, async () => {
// Arrange
await setup(removeAuth_removesV6StyleCredentials)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Manually create v6-style credentials that would be left by v6
const credentialsFileName =
'git-credentials-12345678-1234-1234-1234-123456789abc.config'
const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
const basicCredential = Buffer.from(
`x-access-token:${settings.authToken}`,
'utf8'
).toString('base64')
const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
await fs.promises.writeFile(credentialsFilePath, credentialsContent)
// Add includeIf entries to local git config (simulating v6 configuration)
const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
await fs.promises.appendFile(
localGitConfigPath,
`[includeIf "gitdir:${hostGitDir}/"]\n\tpath = ${credentialsFilePath}\n`
)
await fs.promises.appendFile(
localGitConfigPath,
`[includeIf "gitdir:/github/workspace/.git/"]\n\tpath = /github/runner_temp/${credentialsFileName}\n`
)
// Verify v6 style config exists
let gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
expect(
gitConfigContent.indexOf(credentialsFilePath)
).toBeGreaterThanOrEqual(0)
await fs.promises.stat(credentialsFilePath) // Verify file exists
// Mock the git methods to handle v6 cleanup
const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
mockTryGetConfigKeys.mockResolvedValue([
`includeIf.gitdir:${hostGitDir}/.path`,
'includeIf.gitdir:/github/workspace/.git/.path'
])
const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
mockTryGetConfigValues.mockImplementation(async (key: string) => {
if (key === `includeIf.gitdir:${hostGitDir}/.path`) {
return [credentialsFilePath]
}
if (key === 'includeIf.gitdir:/github/workspace/.git/.path') {
return [`/github/runner_temp/${credentialsFileName}`]
}
return []
})
const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
any,
any
>
mockTryConfigUnsetValue.mockImplementation(
async (
key: string,
value: string,
globalConfig?: boolean,
configPath?: string
) => {
const targetPath = configPath || localGitConfigPath
let content = await fs.promises.readFile(targetPath, 'utf8')
// Remove the includeIf section
const lines = content
.split('\n')
.filter(line => !line.includes('includeIf') && !line.includes(value))
await fs.promises.writeFile(targetPath, lines.join('\n'))
return true
}
)
// Act
await authHelper.removeAuth()
// Assert includeIf entries removed from local git config
gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0)
expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
// Assert credentials config file deleted
try {
await fs.promises.stat(credentialsFilePath)
throw new Error('Credentials file should have been deleted')
} catch (err) {
if ((err as any)?.code !== 'ENOENT') {
throw err
}
}
})
const removeAuth_removesV6StyleCredentialsFromSubmodules =
'removeAuth removes v6 style credentials from submodules'
it(removeAuth_removesV6StyleCredentialsFromSubmodules, async () => {
// Arrange
await setup(removeAuth_removesV6StyleCredentialsFromSubmodules)
// Create fake submodule config paths
const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
const submodule1ConfigPath = path.join(submodule1Dir, 'config')
await fs.promises.mkdir(submodule1Dir, {recursive: true})
await fs.promises.writeFile(submodule1ConfigPath, '')
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Create v6-style credentials file
const credentialsFileName =
'git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config'
const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
const basicCredential = Buffer.from(
`x-access-token:${settings.authToken}`,
'utf8'
).toString('base64')
const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
await fs.promises.writeFile(credentialsFilePath, credentialsContent)
// Add includeIf entries to submodule config
const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
await fs.promises.appendFile(
submodule1ConfigPath,
`[includeIf "gitdir:${submodule1GitDir}/"]\n\tpath = ${credentialsFilePath}\n`
)
// Verify submodule config has includeIf entry
let submoduleConfigContent = (
await fs.promises.readFile(submodule1ConfigPath)
).toString()
expect(submoduleConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(
0
)
expect(
submoduleConfigContent.indexOf(credentialsFilePath)
).toBeGreaterThanOrEqual(0)
// Mock getSubmoduleConfigPaths
const mockGetSubmoduleConfigPaths =
git.getSubmoduleConfigPaths as jest.Mock<any, any>
mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath])
// Mock tryGetConfigKeys for submodule
const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
mockTryGetConfigKeys.mockImplementation(
async (pattern: string, globalConfig?: boolean, configPath?: string) => {
if (configPath === submodule1ConfigPath) {
return [`includeIf.gitdir:${submodule1GitDir}/.path`]
}
return []
}
)
// Mock tryGetConfigValues for submodule
const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
mockTryGetConfigValues.mockImplementation(
async (key: string, globalConfig?: boolean, configPath?: string) => {
if (
configPath === submodule1ConfigPath &&
key === `includeIf.gitdir:${submodule1GitDir}/.path`
) {
return [credentialsFilePath]
}
return []
}
)
// Mock tryConfigUnsetValue for submodule
const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
any,
any
>
mockTryConfigUnsetValue.mockImplementation(
async (
key: string,
value: string,
globalConfig?: boolean,
configPath?: string
) => {
const targetPath = configPath || localGitConfigPath
let content = await fs.promises.readFile(targetPath, 'utf8')
const lines = content
.split('\n')
.filter(line => !line.includes('includeIf') && !line.includes(value))
await fs.promises.writeFile(targetPath, lines.join('\n'))
return true
}
)
// Act
await authHelper.removeAuth()
// Assert submodule includeIf entries removed
submoduleConfigContent = (
await fs.promises.readFile(submodule1ConfigPath)
).toString()
expect(submoduleConfigContent.indexOf('includeIf')).toBeLessThan(0)
expect(submoduleConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
// Assert credentials file deleted
try {
await fs.promises.stat(credentialsFilePath)
throw new Error('Credentials file should have been deleted')
} catch (err) {
if ((err as any)?.code !== 'ENOENT') {
throw err
}
}
})
const removeAuth_skipsV6CleanupWhenEnvVarSet =
'removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is set'
it(removeAuth_skipsV6CleanupWhenEnvVarSet, async () => {
// Arrange
await setup(removeAuth_skipsV6CleanupWhenEnvVarSet)
// Set the skip environment variable
process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] = '1'
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Create v6-style credentials file in RUNNER_TEMP
const credentialsFileName = 'git-credentials-test-uuid-1234-5678.config'
const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
const credentialsContent =
'[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic token\n'
await fs.promises.writeFile(credentialsFilePath, credentialsContent)
// Add includeIf section to local git config (separate from http.* config)
const includeIfSection = `\n[includeIf "gitdir:/some/path/.git/"]\n\tpath = ${credentialsFilePath}\n`
await fs.promises.appendFile(localGitConfigPath, includeIfSection)
// Verify v6 style config exists
let gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
await fs.promises.stat(credentialsFilePath) // Verify file exists
// Act
await authHelper.removeAuth()
// Assert v5 cleanup still happened (http.* removed)
gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(
gitConfigContent.indexOf('http.https://github.com/.extraheader')
).toBeLessThan(0)
// Assert v6 cleanup was skipped - includeIf should still be present
expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
expect(
gitConfigContent.indexOf(credentialsFilePath)
).toBeGreaterThanOrEqual(0)
// Assert credentials file still exists (wasn't deleted)
await fs.promises.stat(credentialsFilePath) // File should still exist
// Assert debug message was logged
expect(core.debug).toHaveBeenCalledWith(
'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
)
// Cleanup
delete process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
})
const removeGlobalConfig_removesOverride =
'removeGlobalConfig removes override'
it(removeGlobalConfig_removesOverride, async () => {
@@ -796,6 +1073,18 @@ async function setup(testName: string): Promise<void> {
),
tryDisableAutomaticGarbageCollection: jest.fn(),
tryGetFetchUrl: jest.fn(),
getSubmoduleConfigPaths: jest.fn(async () => {
return []
}),
tryConfigUnsetValue: jest.fn(async () => {
return true
}),
tryGetConfigValues: jest.fn(async () => {
return []
}),
tryGetConfigKeys: jest.fn(async () => {
return []
}),
tryReset: jest.fn(),
version: jest.fn()
}
@@ -824,7 +1113,8 @@ async function setup(testName: string): Promise<void> {
sshUser: '',
workflowOrganizationId: 123456,
setSafeDirectory: true,
githubServerUrl: githubServerUrl
githubServerUrl: githubServerUrl,
allowUnsafePrCheckout: false
}
}
+12
View File
@@ -499,6 +499,18 @@ async function setup(testName: string): Promise<void> {
await fs.promises.stat(path.join(repositoryPath, '.git'))
return repositoryUrl
}),
getSubmoduleConfigPaths: jest.fn(async () => {
return []
}),
tryConfigUnsetValue: jest.fn(async () => {
return true
}),
tryGetConfigValues: jest.fn(async () => {
return []
}),
tryGetConfigKeys: jest.fn(async () => {
return []
}),
tryReset: jest.fn(async () => {
return true
}),
+1
View File
@@ -91,6 +91,7 @@ describe('input-helper tests', () => {
expect(settings.repositoryOwner).toBe('some-owner')
expect(settings.repositoryPath).toBe(gitHubWorkspace)
expect(settings.setSafeDirectory).toBe(true)
expect(settings.allowUnsafePrCheckout).toBe(false)
})
it('qualifies ref', async () => {
+267
View File
@@ -0,0 +1,267 @@
import * as github from '@actions/github'
import {assertSafePrCheckout} from '../lib/unsafe-pr-checkout-helper'
// Shallow clone original @actions/github context
const originalContext = {...github.context}
const originalEventName = github.context.eventName
const originalPayload = github.context.payload
const BASE_REPO_ID = 100
const FORK_REPO_ID = 200
const PR_HEAD_SHA = '1111111111111111111111111111111111111111'
const PR_MERGE_SHA = '2222222222222222222222222222222222222222'
const SAFE_BASE_SHA = '3333333333333333333333333333333333333333'
const WORKFLOW_RUN_HEAD_COMMIT_SHA = '4444444444444444444444444444444444444444'
const BASE_QUALIFIED_REPO = 'some-owner/some-repo'
const FORK_QUALIFIED_REPO = 'another-repo/fork'
function setContext(eventName: string, payload: object): void {
;(github.context as {eventName: string}).eventName = eventName
;(github.context as {payload: object}).payload = payload
}
function forkPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
},
merge_commit_sha: PR_MERGE_SHA
}
}
}
function sameRepoPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: BASE_REPO_ID, full_name: BASE_QUALIFIED_REPO}
},
merge_commit_sha: PR_MERGE_SHA
}
}
}
function forkWorkflowRunPayload(): object {
return {
repository: {id: BASE_REPO_ID},
workflow_run: {
event: 'pull_request',
head_commit: {id: WORKFLOW_RUN_HEAD_COMMIT_SHA},
head_repository: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
}
}
}
describe('unsafe-pr-checkout-helper', () => {
beforeAll(() => {
jest.spyOn(github.context, 'repo', 'get').mockReturnValue({
owner: 'some-owner',
repo: 'some-repo'
})
})
afterEach(() => {
;(github.context as {eventName: string}).eventName = originalEventName
;(github.context as {payload: object}).payload = originalPayload
})
afterAll(() => {
;(github.context as {eventName: string}).eventName =
originalContext.eventName
;(github.context as {payload: object}).payload = originalContext.payload
jest.restoreAllMocks()
})
it('allows pull_request events untouched', () => {
setContext('pull_request', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'attacker/fork',
ref: 'refs/pull/1/merge',
commit: '',
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('allows pull_request_target default checkout (base branch)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/heads/main',
commit: SAFE_BASE_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('allows same-repo pull_request_target checkout of PR head', () => {
setContext('pull_request_target', sameRepoPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('refuses pull_request_target fork PR head SHA checkout', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).toThrow(/Refusing to check out fork pull request code/)
})
it('refuses pull_request_target fork PR merge_commit_sha checkout', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_MERGE_SHA,
allowUnsafePrCheckout: false
})
).toThrow(/allow-unsafe-pr-checkout/)
})
it('refuses pull_request_target fork PR ref pattern (head)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/head',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target fork PR ref pattern (merge)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/merge',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target when repository points at the fork', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: FORK_QUALIFIED_REPO,
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows pull_request_target checkout of an unrelated third-party repo', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'some-other/unrelated',
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('refuses pull_request_target ignoring repository case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: FORK_QUALIFIED_REPO.toUpperCase(),
ref: '',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target ignoring commit SHA case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA.toUpperCase(),
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows pull_request_target fork PR checkout when opted in', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/merge',
commit: '',
allowUnsafePrCheckout: true
})
).not.toThrow()
})
it('refuses workflow_run fork PR head_commit.id checkout', () => {
setContext('workflow_run', forkWorkflowRunPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses workflow_run with pull_request_target underlying event', () => {
const payload = forkWorkflowRunPayload() as {
workflow_run: {event: string}
}
payload.workflow_run.event = 'pull_request_target'
setContext('workflow_run', payload)
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows workflow_run same-repo PR (head_repository.id matches base)', () => {
const payload = forkWorkflowRunPayload() as {
workflow_run: {head_repository: {id: number}}
}
payload.workflow_run.head_repository.id = BASE_REPO_ID
setContext('workflow_run', payload)
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
})
+10 -1
View File
@@ -98,12 +98,21 @@ inputs:
github-server-url:
description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com
required: false
allow-unsafe-pr-checkout:
description: >
Required to check out fork pull request code from a workflow triggered by
`pull_request_target` or `workflow_run`. These workflows run with the
base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and
runner access; fetching and executing a fork's code in that trusted
context commonly leads to "pwn request" vulnerabilities. Set to `true`
only after reviewing the risks at https://gh.io/securely-using-pull_request_target.
default: false
outputs:
ref:
description: 'The branch, tag or SHA that was checked out'
commit:
description: 'The commit SHA that was checked out'
runs:
using: node20
using: node24
main: dist/index.js
post: dist/index.js
+262 -1
View File
@@ -411,8 +411,50 @@ class GitAuthHelper {
}
removeToken() {
return __awaiter(this, void 0, void 0, function* () {
// HTTP extra header
// Remove HTTP extra header from local git config and submodule configs
yield this.removeGitConfig(this.tokenConfigKey);
//
// Cleanup actions/checkout@v6 style credentials
//
const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'];
if (skipV6Cleanup === '1' || (skipV6Cleanup === null || skipV6Cleanup === void 0 ? void 0 : skipV6Cleanup.toLowerCase()) === 'true') {
core.debug('Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP');
return;
}
try {
// Collect credentials config paths that need to be removed
const credentialsPaths = new Set();
// Remove includeIf entries that point to git-credentials-*.config files
const mainCredentialsPaths = yield this.removeIncludeIfCredentials();
mainCredentialsPaths.forEach(path => credentialsPaths.add(path));
// Remove submodule includeIf entries that point to git-credentials-*.config files
try {
const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
for (const configPath of submoduleConfigPaths) {
const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
}
}
catch (err) {
core.debug(`Unable to get submodule config paths: ${err}`);
}
// Remove credentials config files
for (const credentialsPath of credentialsPaths) {
// Only remove credentials config files if they are under RUNNER_TEMP
const runnerTemp = process.env['RUNNER_TEMP'];
if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
try {
yield io.rmRF(credentialsPath);
}
catch (err) {
core.debug(`Failed to remove credentials config '${credentialsPath}': ${err}`);
}
}
}
}
catch (err) {
core.debug(`Failed to cleanup v6 style credentials: ${err}`);
}
});
}
removeGitConfig(configKey_1) {
@@ -430,6 +472,49 @@ class GitAuthHelper {
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true);
});
}
/**
* Removes includeIf entries that point to git-credentials-*.config files.
* This handles cleanup of credentials configured by newer versions of the action.
* @param configPath Optional path to a specific git config file to operate on
* @returns Array of unique credentials config file paths that were found and removed
*/
removeIncludeIfCredentials(configPath) {
return __awaiter(this, void 0, void 0, function* () {
const credentialsPaths = new Set();
try {
// Get all includeIf.gitdir keys
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
configPath);
for (const key of keys) {
// Get all values for this key
const values = yield this.git.tryGetConfigValues(key, false, // globalConfig?
configPath);
if (values.length > 0) {
// Remove only values that match git-credentials-<uuid>.config pattern
for (const value of values) {
if (this.testCredentialsConfigPath(value)) {
credentialsPaths.add(value);
yield this.git.tryConfigUnsetValue(key, value, false, configPath);
}
}
}
}
}
catch (err) {
// Ignore errors - this is cleanup code
core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`);
}
return Array.from(credentialsPaths);
});
}
/**
* Tests if a path matches the git-credentials-*.config pattern used by newer versions.
* @param path The path to test
* @returns True if the path matches the credentials config pattern
*/
testCredentialsConfigPath(path) {
return /git-credentials-[0-9a-f-]+\.config$/i.test(path);
}
}
@@ -706,6 +791,16 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch');
});
}
getSubmoduleConfigPaths(recursive) {
return __awaiter(this, void 0, void 0, function* () {
// Get submodule config file paths.
// Use `--show-origin` to get the config file path for each submodule.
const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive);
// Extract config file paths from the output (lines starting with "file:").
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
return configPaths;
});
}
getWorkingDirectory() {
return this.workingDirectory;
}
@@ -836,6 +931,20 @@ class GitCommandManager {
return output.exitCode === 0;
});
}
tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--unset', configKey, configValue);
const output = yield this.execGit(args, true);
return output.exitCode === 0;
});
}
tryDisableAutomaticGarbageCollection() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true);
@@ -855,6 +964,46 @@ class GitCommandManager {
return stdout;
});
}
tryGetConfigValues(configKey, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--get-all', configKey);
const output = yield this.execGit(args, true);
if (output.exitCode !== 0) {
return [];
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim());
});
}
tryGetConfigKeys(pattern, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--name-only', '--get-regexp', pattern);
const output = yield this.execGit(args, true);
if (output.exitCode !== 0) {
return [];
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim());
});
}
tryReset() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['reset', '--hard', 'HEAD'], true);
@@ -1710,6 +1859,7 @@ const core = __importStar(__nccwpck_require__(2186));
const fsHelper = __importStar(__nccwpck_require__(7219));
const github = __importStar(__nccwpck_require__(5438));
const path = __importStar(__nccwpck_require__(1017));
const unsafePrCheckoutHelper = __importStar(__nccwpck_require__(843));
const workflowContextHelper = __importStar(__nccwpck_require__(9568));
function getInputs() {
return __awaiter(this, void 0, void 0, function* () {
@@ -1831,6 +1981,17 @@ function getInputs() {
// Determine the GitHub URL that the repository is being hosted from
result.githubServerUrl = core.getInput('github-server-url');
core.debug(`GitHub Host URL = ${result.githubServerUrl}`);
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs)
result.allowUnsafePrCheckout =
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE';
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`);
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
});
return result;
});
}
@@ -1971,6 +2132,7 @@ exports.getRefSpecForAllHistory = getRefSpecForAllHistory;
exports.getRefSpec = getRefSpec;
exports.testRef = testRef;
exports.checkCommitInfo = checkCommitInfo;
exports.fromPayload = fromPayload;
const core = __importStar(__nccwpck_require__(2186));
const github = __importStar(__nccwpck_require__(5438));
const url_helper_1 = __nccwpck_require__(9437);
@@ -2403,6 +2565,105 @@ if (!exports.IsPost) {
}
/***/ }),
/***/ 843:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.assertSafePrCheckout = assertSafePrCheckout;
const github = __importStar(__nccwpck_require__(5438));
const ref_helper_1 = __nccwpck_require__(8601);
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/;
function assertSafePrCheckout(input) {
if (input.allowUnsafePrCheckout) {
return;
}
const eventName = github.context.eventName;
if (eventName !== 'pull_request_target' && eventName !== 'workflow_run') {
return;
}
const baseRepoId = (0, ref_helper_1.fromPayload)('repository.id');
if (typeof baseRepoId !== 'number') {
return;
}
let prHeadRepoId;
let prHeadRepoFullName;
const prShas = [];
if (eventName === 'pull_request_target') {
prHeadRepoId = (0, ref_helper_1.fromPayload)('pull_request.head.repo.id');
prHeadRepoFullName = (0, ref_helper_1.fromPayload)('pull_request.head.repo.full_name');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('pull_request.head.sha'));
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('pull_request.merge_commit_sha'));
}
else {
const wrEvent = (0, ref_helper_1.fromPayload)('workflow_run.event');
if (typeof wrEvent !== 'string' || !wrEvent.startsWith('pull_request')) {
return;
}
prHeadRepoId = (0, ref_helper_1.fromPayload)('workflow_run.head_repository.id');
prHeadRepoFullName = (0, ref_helper_1.fromPayload)('workflow_run.head_repository.full_name');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_commit.id'));
// For `pull_request_target`-triggered workflow_run, `head_sha` is the base
// default branch SHA (not the PR head)
if (wrEvent !== 'pull_request_target') {
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_sha'));
}
}
// (A) Fork PR?
if (typeof prHeadRepoId !== 'number' || prHeadRepoId === baseRepoId) {
return;
}
// (B) We cannot check for all fork PR refs so check to see
// if the resolved input points to the fork PR sha we have in the payload
const repositoryMatchesPrHead = typeof prHeadRepoFullName === 'string' &&
input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase();
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref);
const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase());
if (!repositoryMatchesPrHead &&
!refMatchesPullPattern &&
!commitMatchesPrHeadSha) {
return;
}
throw new Error(`Refusing to check out fork pull request code from a '${eventName}' workflow. ` +
`This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch ` +
`cache scope, and runner access. Fetching and executing a fork's code in that trusted ` +
`context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks ` +
`at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' ` +
`on the actions/checkout step.`);
}
function pushIfSha(target, value) {
if (typeof value === 'string' && value.length > 0) {
target.push(value.toLowerCase());
}
}
/***/ }),
/***/ 9437:
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "checkout",
"version": "4.3.0",
"version": "5.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "checkout",
"version": "4.3.0",
"version": "5.0.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.10.1",
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^20.12.12",
"@types/node": "^24.1.0",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
@@ -1515,12 +1515,12 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
"undici-types": "~7.8.0"
}
},
"node_modules/@types/stack-utils": {
@@ -6865,9 +6865,9 @@
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true
},
"node_modules/universal-user-agent": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "checkout",
"version": "4.3.0",
"version": "5.0.0",
"description": "checkout action",
"main": "lib/main.js",
"scripts": {
@@ -37,7 +37,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^20.12.12",
"@types/node": "^24.1.0",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
+106 -1
View File
@@ -346,8 +346,58 @@ class GitAuthHelper {
}
private async removeToken(): Promise<void> {
// HTTP extra header
// Remove HTTP extra header from local git config and submodule configs
await this.removeGitConfig(this.tokenConfigKey)
//
// Cleanup actions/checkout@v6 style credentials
//
const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
if (skipV6Cleanup === '1' || skipV6Cleanup?.toLowerCase() === 'true') {
core.debug(
'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
)
return
}
try {
// Collect credentials config paths that need to be removed
const credentialsPaths = new Set<string>()
// Remove includeIf entries that point to git-credentials-*.config files
const mainCredentialsPaths = await this.removeIncludeIfCredentials()
mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
// Remove submodule includeIf entries that point to git-credentials-*.config files
try {
const submoduleConfigPaths =
await this.git.getSubmoduleConfigPaths(true)
for (const configPath of submoduleConfigPaths) {
const submoduleCredentialsPaths =
await this.removeIncludeIfCredentials(configPath)
submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
}
} catch (err) {
core.debug(`Unable to get submodule config paths: ${err}`)
}
// Remove credentials config files
for (const credentialsPath of credentialsPaths) {
// Only remove credentials config files if they are under RUNNER_TEMP
const runnerTemp = process.env['RUNNER_TEMP']
if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
try {
await io.rmRF(credentialsPath)
} catch (err) {
core.debug(
`Failed to remove credentials config '${credentialsPath}': ${err}`
)
}
}
}
} catch (err) {
core.debug(`Failed to cleanup v6 style credentials: ${err}`)
}
}
private async removeGitConfig(
@@ -371,4 +421,59 @@ class GitAuthHelper {
true
)
}
/**
* Removes includeIf entries that point to git-credentials-*.config files.
* This handles cleanup of credentials configured by newer versions of the action.
* @param configPath Optional path to a specific git config file to operate on
* @returns Array of unique credentials config file paths that were found and removed
*/
private async removeIncludeIfCredentials(
configPath?: string
): Promise<string[]> {
const credentialsPaths = new Set<string>()
try {
// Get all includeIf.gitdir keys
const keys = await this.git.tryGetConfigKeys(
'^includeIf\\.gitdir:',
false, // globalConfig?
configPath
)
for (const key of keys) {
// Get all values for this key
const values = await this.git.tryGetConfigValues(
key,
false, // globalConfig?
configPath
)
if (values.length > 0) {
// Remove only values that match git-credentials-<uuid>.config pattern
for (const value of values) {
if (this.testCredentialsConfigPath(value)) {
credentialsPaths.add(value)
await this.git.tryConfigUnsetValue(key, value, false, configPath)
}
}
}
}
} catch (err) {
// Ignore errors - this is cleanup code
core.debug(
`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`
)
}
return Array.from(credentialsPaths)
}
/**
* Tests if a path matches the git-credentials-*.config pattern used by newer versions.
* @param path The path to test
* @returns True if the path matches the credentials config pattern
*/
private testCredentialsConfigPath(path: string): boolean {
return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
}
}
+100
View File
@@ -41,6 +41,7 @@ export interface IGitCommandManager {
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
@@ -59,8 +60,24 @@ export interface IGitCommandManager {
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]>
tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
}
@@ -323,6 +340,21 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch')
}
async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
// Get submodule config file paths.
// Use `--show-origin` to get the config file path for each submodule.
const output = await this.submoduleForeach(
`git config --local --show-origin --name-only --get-regexp remote.origin.url`,
recursive
)
// Extract config file paths from the output (lines starting with "file:").
const configPaths =
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
return configPaths
}
getWorkingDirectory(): string {
return this.workingDirectory
}
@@ -455,6 +487,24 @@ class GitCommandManager {
return output.exitCode === 0
}
async tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--unset', configKey, configValue)
const output = await this.execGit(args, true)
return output.exitCode === 0
}
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
@@ -481,6 +531,56 @@ class GitCommandManager {
return stdout
}
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--get-all', configKey)
const output = await this.execGit(args, true)
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim())
}
async tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--name-only', '--get-regexp', pattern)
const output = await this.execGit(args, true)
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim())
}
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
return output.exitCode === 0
+6
View File
@@ -118,4 +118,10 @@ export interface IGitSourceSettings {
* User override on the GitHub Server/Host URL that hosts the repository to be cloned
*/
githubServerUrl: string | undefined
/**
* Opt-in to allow checking out fork pull request code from a workflow
* triggered by pull_request_target or workflow_run.
*/
allowUnsafePrCheckout: boolean
}
+14
View File
@@ -2,6 +2,7 @@ import * as core from '@actions/core'
import * as fsHelper from './fs-helper'
import * as github from '@actions/github'
import * as path from 'path'
import * as unsafePrCheckoutHelper from './unsafe-pr-checkout-helper'
import * as workflowContextHelper from './workflow-context-helper'
import {IGitSourceSettings} from './git-source-settings'
@@ -161,5 +162,18 @@ export async function getInputs(): Promise<IGitSourceSettings> {
result.githubServerUrl = core.getInput('github-server-url')
core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs)
result.allowUnsafePrCheckout =
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE'
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`)
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
})
return result
}
+1 -1
View File
@@ -120,7 +120,7 @@ function updateUsage(
}
updateUsage(
'actions/checkout@v4',
'actions/checkout@v5',
path.join(__dirname, '..', '..', 'action.yml'),
path.join(__dirname, '..', '..', 'README.md')
)
+1 -1
View File
@@ -268,7 +268,7 @@ export async function checkCommitInfo(
}
}
function fromPayload(path: string): any {
export function fromPayload(path: string): any {
return select(github.context.payload, path)
}
+88
View File
@@ -0,0 +1,88 @@
import * as github from '@actions/github'
import {fromPayload} from './ref-helper'
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/
export interface IUnsafePrCheckoutInput {
qualifiedRepository: string
ref: string
commit: string | undefined
allowUnsafePrCheckout: boolean
}
export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void {
if (input.allowUnsafePrCheckout) {
return
}
const eventName = github.context.eventName
if (eventName !== 'pull_request_target' && eventName !== 'workflow_run') {
return
}
const baseRepoId = fromPayload('repository.id')
if (typeof baseRepoId !== 'number') {
return
}
let prHeadRepoId: unknown
let prHeadRepoFullName: unknown
const prShas: string[] = []
if (eventName === 'pull_request_target') {
prHeadRepoId = fromPayload('pull_request.head.repo.id')
prHeadRepoFullName = fromPayload('pull_request.head.repo.full_name')
pushIfSha(prShas, fromPayload('pull_request.head.sha'))
pushIfSha(prShas, fromPayload('pull_request.merge_commit_sha'))
} else {
const wrEvent = fromPayload('workflow_run.event')
if (typeof wrEvent !== 'string' || !wrEvent.startsWith('pull_request')) {
return
}
prHeadRepoId = fromPayload('workflow_run.head_repository.id')
prHeadRepoFullName = fromPayload('workflow_run.head_repository.full_name')
pushIfSha(prShas, fromPayload('workflow_run.head_commit.id'))
// For `pull_request_target`-triggered workflow_run, `head_sha` is the base
// default branch SHA (not the PR head)
if (wrEvent !== 'pull_request_target') {
pushIfSha(prShas, fromPayload('workflow_run.head_sha'))
}
}
// (A) Fork PR?
if (typeof prHeadRepoId !== 'number' || prHeadRepoId === baseRepoId) {
return
}
// (B) We cannot check for all fork PR refs so check to see
// if the resolved input points to the fork PR sha we have in the payload
const repositoryMatchesPrHead =
typeof prHeadRepoFullName === 'string' &&
input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase()
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref)
const commitMatchesPrHeadSha =
!!input.commit && prShas.includes(input.commit.toLowerCase())
if (
!repositoryMatchesPrHead &&
!refMatchesPullPattern &&
!commitMatchesPrHeadSha
) {
return
}
throw new Error(
`Refusing to check out fork pull request code from a '${eventName}' workflow. ` +
`This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch ` +
`cache scope, and runner access. Fetching and executing a fork's code in that trusted ` +
`context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks ` +
`at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' ` +
`on the actions/checkout step.`
)
}
function pushIfSha(target: string[], value: unknown): void {
if (typeof value === 'string' && value.length > 0) {
target.push(value.toLowerCase())
}
}