1
0
mirror of https://github.com/actions/checkout.git synced 2026-06-14 16:53:47 +08:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Yashwanth Anantharaju
67bd696108 Fix checkout init for SHA-256 repositories 2026-05-21 16:56:34 -04:00
17 changed files with 406 additions and 530 deletions

View File

@@ -1,9 +1,5 @@
# Changelog
## v6.0.3
* Fix checkout init for SHA-256 repositories by @yaananth in https://github.com/actions/checkout/pull/2439
* fix: expand merge commit SHA regex and add SHA-256 test cases by @yaananth in https://github.com/actions/checkout/pull/2414
## v6.0.2
* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356

View File

@@ -160,12 +160,6 @@ 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`. See [Pwn Requests](todo:need-link) for
# the risks. Set to `true` only after reviewing the risks.
# Default: false
allow-unsafe-pr-checkout: ''
```
<!-- end usage -->

View File

@@ -1103,6 +1103,7 @@ async function setup(testName: string): Promise<void> {
),
tryDisableAutomaticGarbageCollection: jest.fn(),
tryGetFetchUrl: jest.fn(),
tryGetObjectFormat: jest.fn(async () => ({format: '', succeeded: true})),
tryGetConfigValues: jest.fn(
async (
key: string,
@@ -1173,8 +1174,7 @@ async function setup(testName: string): Promise<void> {
sshUser: '',
workflowOrganizationId: 123456,
setSafeDirectory: true,
githubServerUrl: githubServerUrl,
allowUnsafePrCheckout: false
githubServerUrl: githubServerUrl
}
}

View File

@@ -378,7 +378,7 @@ describe('Test fetchDepth and fetchTags options', () => {
})
})
describe('repository initialization object format', () => {
describe('repository object format', () => {
beforeEach(async () => {
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
@@ -388,6 +388,116 @@ describe('repository initialization object format', () => {
jest.restoreAllMocks()
})
it('detects SHA-256 from a 64-character HEAD oid', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
if (args.includes('ls-remote')) {
options.listeners.stdout(
Buffer.from(
'ref: refs/heads/main\tHEAD\n' +
'9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92\tHEAD\n'
)
)
}
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
git = await commandManager.createCommandManager('test', false, false)
const objectFormat = await git.tryGetObjectFormat(
'https://github.com/example/repo'
)
expect(objectFormat).toEqual({format: 'sha256', succeeded: true})
expect(mockExec).toHaveBeenCalledWith(
expect.any(String),
[
'-c',
'protocol.version=2',
'ls-remote',
'--quiet',
'--exit-code',
'--symref',
'https://github.com/example/repo',
'HEAD'
],
expect.objectContaining({
ignoreReturnCode: true,
silent: true
})
)
})
it('detects SHA-1 from a 40-character HEAD oid', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
if (args.includes('ls-remote')) {
options.listeners.stdout(
Buffer.from(
'ref: refs/heads/main\tHEAD\n' +
'c988866043f035e6a46509872215f91d879044c9\tHEAD\n'
)
)
}
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
git = await commandManager.createCommandManager('test', false, false)
await expect(
git.tryGetObjectFormat('https://github.com/example/repo')
).resolves.toEqual({format: 'sha1', succeeded: true})
})
it('returns unsuccessful when HEAD does not resolve to a recognized object id', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
if (args.includes('ls-remote')) {
options.listeners.stdout(Buffer.from('ref: refs/heads/main\tHEAD\n'))
}
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
git = await commandManager.createCommandManager('test', false, false)
await expect(
git.tryGetObjectFormat('https://github.com/example/repo')
).resolves.toEqual({format: '', succeeded: false})
})
it('returns unsuccessful when object format detection cannot reach the remote', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
return 0
}
return 128
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
git = await commandManager.createCommandManager('test', false, false)
await expect(
git.tryGetObjectFormat('https://github.com/example/repo')
).resolves.toEqual({format: '', succeeded: false})
})
it('initializes SHA-256 repositories with the matching object format', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {

View File

@@ -501,6 +501,7 @@ async function setup(testName: string): Promise<void> {
await fs.promises.stat(path.join(repositoryPath, '.git'))
return repositoryUrl
}),
tryGetObjectFormat: jest.fn(async () => ({format: '', succeeded: true})),
tryGetConfigValues: jest.fn(),
tryGetConfigKeys: jest.fn(),
tryReset: jest.fn(async () => {

View File

@@ -5,16 +5,29 @@ import * as githubApiHelper from '../lib/github-api-helper'
describe('github-api-helper object format', () => {
let getOctokitSpy: jest.SpyInstance
let debugSpy: jest.SpyInstance
let request: jest.Mock
let repoGet: jest.Mock
let branchGet: jest.Mock
function mockHashAlgorithmApi(hashAlgorithm: string): void {
request = jest.fn(async () => ({
function mockObjectFormatApi(defaultBranch: string, commitSha: string): void {
repoGet = jest.fn(async () => ({
data: {
hash_algorithm: hashAlgorithm
default_branch: defaultBranch
}
}))
branchGet = jest.fn(async () => ({
data: {
commit: {
sha: commitSha
}
}
}))
getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({
request
rest: {
repos: {
get: repoGet,
getBranch: branchGet
}
}
} as any)
}
@@ -26,29 +39,37 @@ describe('github-api-helper object format', () => {
jest.restoreAllMocks()
})
it('detects SHA-256 from the repository hash algorithm endpoint', async () => {
mockHashAlgorithmApi('sha256')
it('detects SHA-256 from the default branch commit SHA', async () => {
const commitSha =
'9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92'
mockObjectFormatApi('main', commitSha)
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: 'sha256', succeeded: true})
).resolves.toEqual({
defaultBranch: 'main',
format: 'sha256',
succeeded: true
})
expect(getOctokitSpy).toHaveBeenCalledWith(
'token',
expect.objectContaining({baseUrl: 'https://api.github.com'})
)
expect(request).toHaveBeenCalledWith(
'GET /repos/{owner}/{repo}/hash-algorithm',
{owner: 'owner', repo: 'repo'}
)
expect(repoGet).toHaveBeenCalledWith({owner: 'owner', repo: 'repo'})
expect(branchGet).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
branch: 'main'
})
})
it('detects SHA-1 from the repository hash algorithm endpoint', async () => {
mockHashAlgorithmApi('sha1')
it('detects SHA-1 from the default branch commit SHA', async () => {
mockObjectFormatApi('main', 'c988866043f035e6a46509872215f91d879044c9')
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: 'sha1', succeeded: true})
).resolves.toEqual({defaultBranch: 'main', format: 'sha1', succeeded: true})
})
it('detects object format from an existing commit without API calls', async () => {
@@ -62,6 +83,7 @@ describe('github-api-helper object format', () => {
'owner',
'repo',
undefined,
undefined,
commitSha
)
).resolves.toEqual({format: 'sha256', succeeded: true})
@@ -69,30 +91,74 @@ describe('github-api-helper object format', () => {
expect(getOctokitSpy).not.toHaveBeenCalled()
})
it('returns unsuccessful when the hash algorithm endpoint value is not recognized', async () => {
mockHashAlgorithmApi('unknown')
it('uses a branch ref directly without looking up the default branch', async () => {
const commitSha = 'c988866043f035e6a46509872215f91d879044c9'
repoGet = jest.fn()
branchGet = jest.fn(async () => ({
data: {
commit: {
sha: commitSha
}
}
}))
getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: {
repos: {
get: repoGet,
getBranch: branchGet
}
}
} as any)
await expect(
githubApiHelper.tryGetRepositoryObjectFormat(
'token',
'owner',
'repo',
undefined,
'refs/heads/feature'
)
).resolves.toEqual({format: 'sha1', succeeded: true})
expect(repoGet).not.toHaveBeenCalled()
expect(branchGet).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
branch: 'feature'
})
})
it('returns unsuccessful when the default branch commit SHA is not recognized', async () => {
mockObjectFormatApi('main', 'not-a-sha')
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: '', succeeded: false})
expect(debugSpy).toHaveBeenCalledWith(
'Unable to determine repository object format from hash-algorithm endpoint'
'Unable to determine repository object format from commit SHA'
)
})
it('returns unsuccessful when the hash algorithm API lookup fails', async () => {
request = jest.fn(async () => {
it('returns unsuccessful when the repository API lookup fails', async () => {
repoGet = jest.fn(async () => {
throw new Error('not found')
})
branchGet = jest.fn()
jest.spyOn(github, 'getOctokit').mockReturnValue({
request
rest: {
repos: {
get: repoGet,
getBranch: branchGet
}
}
} as any)
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: '', succeeded: false})
expect(branchGet).not.toHaveBeenCalled()
expect(debugSpy).toHaveBeenCalledWith(
'Unable to determine repository object format from hash-algorithm endpoint: not found'
'Unable to determine repository object format: not found'
)
})
})

View File

@@ -91,7 +91,6 @@ 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 () => {

View File

@@ -1,254 +0,0 @@
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'
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}
},
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}
},
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}
}
}
}
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: 'attacker/fork',
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target ignoring repository case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'SOME-OWNER/SOME-REPO',
ref: '',
commit: PR_HEAD_SHA,
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()
})
})

View File

@@ -98,12 +98,6 @@ 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`. See [Pwn Requests](todo:need-link)
for the risks. Set to `true` only after reviewing the risks.
default: false
outputs:
ref:
description: 'The branch, tag or SHA that was checked out'

205
dist/index.js vendored
View File

@@ -1061,6 +1061,45 @@ class GitCommandManager {
return stdout;
});
}
tryGetObjectFormat(repositoryUrl) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
try {
const output = yield this.execGit([
'-c',
'protocol.version=2',
'ls-remote',
'--quiet',
'--exit-code',
'--symref',
repositoryUrl,
'HEAD'
], true, true);
if (output.exitCode !== 0) {
core.debug(`Unable to determine repository object format: git ls-remote exited with ${output.exitCode}`);
return { format: '', succeeded: false };
}
for (const line of output.stdout.trim().split('\n')) {
const [oid, ref] = line.split('\t');
if (ref !== 'HEAD') {
continue;
}
if (/^[0-9a-fA-F]{64}$/.test(oid)) {
return { format: 'sha256', succeeded: true };
}
if (/^[0-9a-fA-F]{40}$/.test(oid)) {
return { format: 'sha1', succeeded: true };
}
}
core.debug('Unable to determine repository object format from HEAD');
return { format: '', succeeded: false };
}
catch (err) {
core.debug(`Unable to determine repository object format: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
return { format: '', succeeded: false };
}
});
}
tryGetConfigValues(configKey, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
@@ -1489,13 +1528,18 @@ function getSource(settings) {
}
// Save state for POST action
stateHelper.setRepositoryPath(settings.repositoryPath);
let defaultBranch = '';
// Initialize the repository
if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) {
core.startGroup('Determining repository object format');
const objectFormatResult = yield githubApiHelper.tryGetRepositoryObjectFormat(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl, settings.commit);
let objectFormatResult = yield githubApiHelper.tryGetRepositoryObjectFormat(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl, settings.ref, settings.commit);
if (!objectFormatResult.succeeded) {
objectFormatResult = yield git.tryGetObjectFormat(repositoryUrl);
}
const objectFormat = objectFormatResult.succeeded
? objectFormatResult.format
: '';
defaultBranch = objectFormatResult.defaultBranch || '';
if (objectFormat === 'sha256') {
core.info('Detected SHA-256 repository object format');
}
@@ -1525,6 +1569,10 @@ function getSource(settings) {
if (settings.sshKey) {
settings.ref = yield git.getDefaultBranch(repositoryUrl);
}
else if (defaultBranch) {
core.info(`Default branch '${defaultBranch}'`);
settings.ref = `refs/heads/${defaultBranch}`;
}
else {
settings.ref = yield githubApiHelper.getDefaultBranch(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl);
}
@@ -1926,31 +1974,60 @@ function getDefaultBranch(authToken, owner, repo, baseUrl) {
}));
});
}
function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl, commit) {
function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl, ref, commit) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const commitFormat = getObjectFormat(commit);
if (commitFormat) {
return { format: commitFormat, succeeded: true };
}
try {
const commitFormat = getObjectFormat(commit);
if (commitFormat) {
return { format: commitFormat, succeeded: true };
}
const octokit = github.getOctokit(authToken, {
baseUrl: (0, url_helper_1.getServerApiUrl)(baseUrl)
});
const response = yield octokit.request('GET /repos/{owner}/{repo}/hash-algorithm', { owner, repo });
const hashAlgorithm = response.data.hash_algorithm;
if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') {
return { format: hashAlgorithm, succeeded: true };
let branchName = getBranchName(ref);
let defaultBranch = '';
if (!branchName) {
const repository = yield octokit.rest.repos.get({ owner, repo });
defaultBranch = repository.data.default_branch;
assert.ok(defaultBranch, 'default_branch cannot be empty');
branchName = defaultBranch;
}
core.debug('Unable to determine repository object format from hash-algorithm endpoint');
const branch = yield octokit.rest.repos.getBranch({
owner,
repo,
branch: branchName
});
const branchFormat = getObjectFormat(branch.data.commit.sha);
if (branchFormat) {
return {
defaultBranch: defaultBranch || undefined,
format: branchFormat,
succeeded: true
};
}
core.debug('Unable to determine repository object format from commit SHA');
return { format: '', succeeded: false };
}
catch (err) {
core.debug(`Unable to determine repository object format from hash-algorithm endpoint: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
core.debug(`Unable to determine repository object format: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
return { format: '', succeeded: false };
}
});
}
function getBranchName(ref) {
if (!ref) {
return '';
}
const headsPrefix = 'refs/heads/';
if (ref.startsWith(headsPrefix)) {
return ref.substring(headsPrefix.length);
}
if (!ref.startsWith('refs/') && !getObjectFormat(ref)) {
return ref;
}
return '';
}
function getObjectFormat(sha) {
if (/^[0-9a-fA-F]{64}$/.test(sha || '')) {
return 'sha256';
@@ -2023,7 +2100,6 @@ 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* () {
@@ -2145,17 +2221,6 @@ 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;
});
}
@@ -2296,7 +2361,6 @@ 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);
@@ -2745,97 +2809,6 @@ 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;
const prShas = [];
if (eventName === 'pull_request_target') {
prHeadRepoId = (0, ref_helper_1.fromPayload)('pull_request.head.repo.id');
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');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_commit.id'));
}
// (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 baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}`;
const repositoryDiffersFromBase = input.qualifiedRepository.toLowerCase() !==
baseQualifiedRepository.toLowerCase();
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref);
const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase());
if (!repositoryDiffersFromBase &&
!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 fork's code in that trusted context is a ` +
`"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, 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:

View File

@@ -15,6 +15,11 @@ import {GitVersion} from './git-version'
export const MinimumGitVersion = new GitVersion('2.18')
export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28')
export interface GitObjectFormatResult {
format: string
succeeded: boolean
}
export interface IGitCommandManager {
branchDelete(remote: boolean, branch: string): Promise<void>
branchExists(remote: boolean, pattern: string): Promise<boolean>
@@ -68,6 +73,7 @@ export interface IGitCommandManager {
): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
tryGetObjectFormat(repositoryUrl: string): Promise<GitObjectFormatResult>
tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
@@ -542,6 +548,55 @@ class GitCommandManager {
return stdout
}
async tryGetObjectFormat(
repositoryUrl: string
): Promise<GitObjectFormatResult> {
try {
const output = await this.execGit(
[
'-c',
'protocol.version=2',
'ls-remote',
'--quiet',
'--exit-code',
'--symref',
repositoryUrl,
'HEAD'
],
true,
true
)
if (output.exitCode !== 0) {
core.debug(
`Unable to determine repository object format: git ls-remote exited with ${output.exitCode}`
)
return {format: '', succeeded: false}
}
for (const line of output.stdout.trim().split('\n')) {
const [oid, ref] = line.split('\t')
if (ref !== 'HEAD') {
continue
}
if (/^[0-9a-fA-F]{64}$/.test(oid)) {
return {format: 'sha256', succeeded: true}
}
if (/^[0-9a-fA-F]{40}$/.test(oid)) {
return {format: 'sha1', succeeded: true}
}
}
core.debug('Unable to determine repository object format from HEAD')
return {format: '', succeeded: false}
} catch (err) {
core.debug(
`Unable to determine repository object format: ${(err as any)?.message ?? err}`
)
return {format: '', succeeded: false}
}
}
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean,

View File

@@ -105,22 +105,29 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Save state for POST action
stateHelper.setRepositoryPath(settings.repositoryPath)
let defaultBranch = ''
// Initialize the repository
if (
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
) {
core.startGroup('Determining repository object format')
const objectFormatResult =
let objectFormatResult =
await githubApiHelper.tryGetRepositoryObjectFormat(
settings.authToken,
settings.repositoryOwner,
settings.repositoryName,
settings.githubServerUrl,
settings.ref,
settings.commit
)
if (!objectFormatResult.succeeded) {
objectFormatResult = await git.tryGetObjectFormat(repositoryUrl)
}
const objectFormat = objectFormatResult.succeeded
? objectFormatResult.format
: ''
defaultBranch = objectFormatResult.defaultBranch || ''
if (objectFormat === 'sha256') {
core.info('Detected SHA-256 repository object format')
}
@@ -155,6 +162,9 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
core.startGroup('Determining the default branch')
if (settings.sshKey) {
settings.ref = await git.getDefaultBranch(repositoryUrl)
} else if (defaultBranch) {
core.info(`Default branch '${defaultBranch}'`)
settings.ref = `refs/heads/${defaultBranch}`
} else {
settings.ref = await githubApiHelper.getDefaultBranch(
settings.authToken,

View File

@@ -118,10 +118,4 @@ 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
}

View File

@@ -12,6 +12,7 @@ import {getServerApiUrl} from './url-helper'
const IS_WINDOWS = process.platform === 'win32'
export interface RepositoryObjectFormatResult {
defaultBranch?: string
format: string
succeeded: boolean
}
@@ -132,38 +133,69 @@ export async function tryGetRepositoryObjectFormat(
owner: string,
repo: string,
baseUrl?: string,
ref?: string,
commit?: string
): Promise<RepositoryObjectFormatResult> {
const commitFormat = getObjectFormat(commit)
if (commitFormat) {
return {format: commitFormat, succeeded: true}
}
try {
const commitFormat = getObjectFormat(commit)
if (commitFormat) {
return {format: commitFormat, succeeded: true}
}
const octokit = github.getOctokit(authToken, {
baseUrl: getServerApiUrl(baseUrl)
})
const response = await octokit.request(
'GET /repos/{owner}/{repo}/hash-algorithm',
{owner, repo}
)
const hashAlgorithm = response.data.hash_algorithm
if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') {
return {format: hashAlgorithm, succeeded: true}
let branchName = getBranchName(ref)
let defaultBranch = ''
if (!branchName) {
const repository = await octokit.rest.repos.get({owner, repo})
defaultBranch = repository.data.default_branch
assert.ok(defaultBranch, 'default_branch cannot be empty')
branchName = defaultBranch
}
core.debug(
'Unable to determine repository object format from hash-algorithm endpoint'
)
const branch = await octokit.rest.repos.getBranch({
owner,
repo,
branch: branchName
})
const branchFormat = getObjectFormat(branch.data.commit.sha)
if (branchFormat) {
return {
defaultBranch: defaultBranch || undefined,
format: branchFormat,
succeeded: true
}
}
core.debug('Unable to determine repository object format from commit SHA')
return {format: '', succeeded: false}
} catch (err) {
core.debug(
`Unable to determine repository object format from hash-algorithm endpoint: ${(err as any)?.message ?? err}`
`Unable to determine repository object format: ${(err as any)?.message ?? err}`
)
return {format: '', succeeded: false}
}
}
function getBranchName(ref?: string): string {
if (!ref) {
return ''
}
const headsPrefix = 'refs/heads/'
if (ref.startsWith(headsPrefix)) {
return ref.substring(headsPrefix.length)
}
if (!ref.startsWith('refs/') && !getObjectFormat(ref)) {
return ref
}
return ''
}
function getObjectFormat(sha?: string): string {
if (/^[0-9a-fA-F]{64}$/.test(sha || '')) {
return 'sha256'

View File

@@ -2,7 +2,6 @@ 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'
@@ -162,18 +161,5 @@ 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
}

View File

@@ -292,7 +292,7 @@ export async function checkCommitInfo(
}
}
export function fromPayload(path: string): any {
function fromPayload(path: string): any {
return select(github.context.payload, path)
}

View File

@@ -1,80 +0,0 @@
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
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
const prShas: string[] = []
if (eventName === 'pull_request_target') {
prHeadRepoId = fromPayload('pull_request.head.repo.id')
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')
pushIfSha(prShas, fromPayload('workflow_run.head_commit.id'))
}
// (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 baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}`
const repositoryDiffersFromBase =
input.qualifiedRepository.toLowerCase() !==
baseQualifiedRepository.toLowerCase()
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref)
const commitMatchesPrHeadSha =
!!input.commit && prShas.includes(input.commit.toLowerCase())
if (
!repositoryDiffersFromBase &&
!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 fork's code in that trusted context is a ` +
`"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, 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())
}
}