Compare commits

...

4 Commits

Author SHA1 Message Date
Kranthi Poturaju 129a18dc81
Merge 7358b1bbf5 into 12cd2235ef 2026-07-16 08:26:10 -04:00
Aiqiao Yan 12cd2235ef
trim only ascii whitespace for branch (#2521)
* trim only ascii whitespace for branch

* rebuild
2026-07-15 15:05:42 -04:00
Aiqiao Yan 62661c4e71
skip running unsafe pr check if input is default (#2518) 2026-07-15 10:02:03 -04:00
Kranthi Poturaju 7358b1bbf5 docs(action): fix inaccuracies and add missing metadata to action.yml 2026-05-08 00:47:52 +05:30
4 changed files with 173 additions and 27 deletions

View File

@ -24,9 +24,20 @@ const mockGithubContext: any = {
payload: {} payload: {}
} }
// Replicate @actions/core getInput behavior: it trims whitespace by default
// (String.prototype.trim(), which strips characters such as a leading U+FEFF BOM)
// unless trimWhitespace is explicitly set to false.
const getInputImpl = (name: string, options?: {trimWhitespace?: boolean}) => {
const val = inputs[name] ?? ''
if (options && options.trimWhitespace === false) {
return val
}
return typeof val === 'string' ? val.trim() : val
}
// Mock @actions/core before loading input-helper // Mock @actions/core before loading input-helper
jest.unstable_mockModule('@actions/core', () => ({ jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn((name: string) => inputs[name]), getInput: jest.fn(getInputImpl),
getBooleanInput: jest.fn((name: string) => inputs[name]), getBooleanInput: jest.fn((name: string) => inputs[name]),
getMultilineInput: jest.fn((name: string) => getMultilineInput: jest.fn((name: string) =>
inputs[name] ? String(inputs[name]).split('\n').filter(Boolean) : [] inputs[name] ? String(inputs[name]).split('\n').filter(Boolean) : []
@ -76,9 +87,7 @@ describe('input-helper tests', () => {
inputs = {} inputs = {}
jest.clearAllMocks() jest.clearAllMocks()
// Re-apply default mocks // Re-apply default mocks
;(core.getInput as jest.Mock<any>).mockImplementation( ;(core.getInput as jest.Mock<any>).mockImplementation(getInputImpl as any)
(name: string) => inputs[name]
)
mockDirectoryExistsSync.mockImplementation( mockDirectoryExistsSync.mockImplementation(
(p: string) => p === gitHubWorkspace (p: string) => p === gitHubWorkspace
) )
@ -176,8 +185,84 @@ describe('input-helper tests', () => {
expect(settings.commit).toBeFalsy() expect(settings.commit).toBeFalsy()
}) })
it('does not reclassify a ref as sha when a BOM is prefixed', async () => {
// A fork branch named "<U+FEFF>" + 40 hex chars. core.getInput trims the
// BOM by default, which previously collapsed this into a bare SHA and
// bypassed the unsafe fork PR checkout guard.
inputs.ref = '\uFEFF522d932fae5296da51fdf431934425ecf891c6a2'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.commit).toBeFalsy()
expect(settings.ref).toBe('522d932fae5296da51fdf431934425ecf891c6a2')
})
it('does not reclassify a sha-256 ref as sha when a BOM is prefixed', async () => {
inputs.ref =
'\uFEFF1111111111222222222233333333334444444444555555555566666666667777'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.commit).toBeFalsy()
expect(settings.ref).toBe(
'1111111111222222222233333333334444444444555555555566666666667777'
)
})
it('treats a sha surrounded by ascii whitespace as a commit', async () => {
// ASCII whitespace can only come from the workflow author's YAML (git ref
// names cannot contain it), so trimming it and treating the value as a
// commit is safe.
inputs.ref = ' 1111111111222222222233333333334444444444 '
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.ref).toBeFalsy()
expect(settings.commit).toBe('1111111111222222222233333333334444444444')
})
it('sets workflow organization ID', async () => { it('sets workflow organization ID', async () => {
const settings: IGitSourceSettings = await inputHelper.getInputs() const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.workflowOrganizationId).toBe(123456) expect(settings.workflowOrganizationId).toBe(123456)
}) })
describe('unsafe PR checkout guard', () => {
const forkPayload = {
repository: {id: 100},
pull_request: {
head: {
sha: '1234567890123456789012345678901234567890',
repo: {id: 200, full_name: 'attacker/fork'}
},
merge_commit_sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
}
}
it('allows the default self-checkout on a fork pull_request_target', async () => {
const originalEvent = mockGithubContext.eventName
const originalPayload = mockGithubContext.payload
try {
mockGithubContext.eventName = 'pull_request_target'
mockGithubContext.payload = forkPayload
// Simulate a rebase/fast-forward merge where the base tip (event SHA)
// equals the PR head SHA. The default self-checkout must still succeed.
mockGithubContext.sha = '1234567890123456789012345678901234567890'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.commit).toBe('1234567890123456789012345678901234567890')
} finally {
mockGithubContext.eventName = originalEvent
mockGithubContext.payload = originalPayload
}
})
it('refuses an explicit fork repository on pull_request_target', async () => {
const originalEvent = mockGithubContext.eventName
const originalPayload = mockGithubContext.payload
try {
mockGithubContext.eventName = 'pull_request_target'
mockGithubContext.payload = forkPayload
inputs.repository = 'attacker/fork'
await expect(inputHelper.getInputs()).rejects.toThrow(
/Refusing to check out fork pull request code/
)
} finally {
mockGithubContext.eventName = originalEvent
mockGithubContext.payload = originalPayload
}
})
})
}) })

View File

@ -9,6 +9,7 @@ inputs:
The branch, tag or SHA to checkout. When checking out the repository that The branch, tag or SHA to checkout. When checking out the repository that
triggered a workflow, this defaults to the reference or SHA for that triggered a workflow, this defaults to the reference or SHA for that
event. Otherwise, uses the default branch. event. Otherwise, uses the default branch.
required: false
token: token:
description: > description: >
Personal access token (PAT) used to fetch the repository. The PAT is configured Personal access token (PAT) used to fetch the repository. The PAT is configured
@ -34,11 +35,13 @@ inputs:
[Learn more about creating and using [Learn more about creating and using
encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
required: false
ssh-known-hosts: ssh-known-hosts:
description: > description: >
Known hosts in addition to the user and global host key database. The public Known hosts in addition to the user and global host key database. The public
SSH keys for a host may be obtained using the utility `ssh-keyscan`. For example, SSH keys for a host may be obtained using the utility `ssh-keyscan`. For example,
`ssh-keyscan github.com`. The public key for github.com is always implicitly added. `ssh-keyscan github.com`. The public key for github.com is always implicitly added.
required: false
ssh-strict: ssh-strict:
description: > description: >
Whether to perform strict host key checking. When true, adds the options `StrictHostKeyChecking=yes` Whether to perform strict host key checking. When true, adds the options `StrictHostKeyChecking=yes`
@ -50,23 +53,30 @@ inputs:
The user to use when connecting to the remote SSH host. By default 'git' is used. The user to use when connecting to the remote SSH host. By default 'git' is used.
default: git default: git
persist-credentials: persist-credentials:
description: 'Whether to configure the token or SSH key with the local git config' description: >
Whether to persist the token or SSH key credentials for use by subsequent
steps. When true, credentials are stored in a file under `$RUNNER_TEMP`
and referenced from the local git config. The post-job step removes the
credential file.
default: true default: true
path: path:
description: 'Relative path under $GITHUB_WORKSPACE to place the repository' description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
default: '.'
required: false
clean: clean:
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching' description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
default: true default: true
filter: filter:
description: > description: >
Partially clone against a given filter. Partially clone against a given filter. When `sparse-checkout` is also
Overrides sparse-checkout if set. set, overrides the default `blob:none` filter that would otherwise be
default: null used for fetching.
required: false
sparse-checkout: sparse-checkout:
description: > description: >
Do a sparse checkout on given patterns. Do a sparse checkout on given patterns.
Each pattern should be separated with new lines. Each pattern should be separated with new lines.
default: null required: false
sparse-checkout-cone-mode: sparse-checkout-cone-mode:
description: > description: >
Specifies whether to use cone-mode when doing a sparse checkout. Specifies whether to use cone-mode when doing a sparse checkout.
@ -109,9 +119,13 @@ inputs:
default: false default: false
outputs: outputs:
ref: ref:
description: 'The branch, tag or SHA that was checked out' description: >
The branch or tag ref that was checked out. Empty when the checkout was
performed by a full commit SHA.
commit: commit:
description: 'The commit SHA that was checked out' description: >
The commit SHA that was checked out. Only set when Git is available (not
set when the repository was downloaded via the GitHub REST API fallback).
runs: runs:
using: node24 using: node24
main: dist/index.js main: dist/index.js

27
dist/index.js vendored
View File

@ -42100,6 +42100,22 @@ async function getInputs() {
`${github_context.repo.owner}/${github_context.repo.repo}`.toUpperCase(); `${github_context.repo.owner}/${github_context.repo.repo}`.toUpperCase();
// Source branch, source version // Source branch, source version
result.ref = getInput('ref'); result.ref = getInput('ref');
// core.getInput()'s default trim strips a range of Unicode characters such as a
// leading BOM (U+FEFF) or NBSP (U+00A0). Those are valid in a git ref name, so
// a fork branch named "<BOM>" + 40 hex chars would trim down to a bare SHA and
// be silently reclassified as a commit, bypassing the unsafe fork PR checkout
// guard.
//
// The trim below strips only the ASCII whitespace characters which are all forbidden
// in a git branch name.
// \t U+0009 horizontal tab - ASCII control, forbidden in ref names
// \n U+000A line feed - ASCII control, forbidden in ref names
// \v U+000B vertical tab - ASCII control, forbidden in ref names
// \f U+000C form feed - ASCII control, forbidden in ref names
// \r U+000D carriage return - ASCII control, forbidden in ref names
// ' ' U+0020 space - forbidden in ref names
const asciiTrimmedRef = getInput('ref', { trimWhitespace: false })
.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, '');
if (!result.ref) { if (!result.ref) {
if (isWorkflowRepository) { if (isWorkflowRepository) {
result.ref = github_context.ref; result.ref = github_context.ref;
@ -42112,8 +42128,8 @@ async function getInputs() {
} }
} }
// SHA? // SHA?
else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) { else if (asciiTrimmedRef.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
result.commit = result.ref; result.commit = asciiTrimmedRef;
result.ref = ''; result.ref = '';
} }
core_debug(`ref = '${result.ref}'`); core_debug(`ref = '${result.ref}'`);
@ -42191,12 +42207,19 @@ async function getInputs() {
(getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() === (getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE'; 'TRUE';
core_debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`); core_debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`);
// The default self-checkout (this repository with no explicit ref) always
// resolves to the trusted ref/commit GitHub set for the triggering event, so
// the fork-checkout guard only needs to run when the caller customized the
// repository or ref.
const isDefaultCheckout = isWorkflowRepository && !getInput('ref');
if (!isDefaultCheckout) {
assertSafePrCheckout({ assertSafePrCheckout({
qualifiedRepository, qualifiedRepository,
ref: result.ref, ref: result.ref,
commit: result.commit, commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout allowUnsafePrCheckout: result.allowUnsafePrCheckout
}); });
}
return result; return result;
} }

View File

@ -59,6 +59,23 @@ export async function getInputs(): Promise<IGitSourceSettings> {
// Source branch, source version // Source branch, source version
result.ref = core.getInput('ref') result.ref = core.getInput('ref')
// core.getInput()'s default trim strips a range of Unicode characters such as a
// leading BOM (U+FEFF) or NBSP (U+00A0). Those are valid in a git ref name, so
// a fork branch named "<BOM>" + 40 hex chars would trim down to a bare SHA and
// be silently reclassified as a commit, bypassing the unsafe fork PR checkout
// guard.
//
// The trim below strips only the ASCII whitespace characters which are all forbidden
// in a git branch name.
// \t U+0009 horizontal tab - ASCII control, forbidden in ref names
// \n U+000A line feed - ASCII control, forbidden in ref names
// \v U+000B vertical tab - ASCII control, forbidden in ref names
// \f U+000C form feed - ASCII control, forbidden in ref names
// \r U+000D carriage return - ASCII control, forbidden in ref names
// ' ' U+0020 space - forbidden in ref names
const asciiTrimmedRef = core
.getInput('ref', {trimWhitespace: false})
.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, '')
if (!result.ref) { if (!result.ref) {
if (isWorkflowRepository) { if (isWorkflowRepository) {
result.ref = github.context.ref result.ref = github.context.ref
@ -72,8 +89,8 @@ export async function getInputs(): Promise<IGitSourceSettings> {
} }
} }
// SHA? // SHA?
else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) { else if (asciiTrimmedRef.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
result.commit = result.ref result.commit = asciiTrimmedRef
result.ref = '' result.ref = ''
} }
core.debug(`ref = '${result.ref}'`) core.debug(`ref = '${result.ref}'`)
@ -168,12 +185,19 @@ export async function getInputs(): Promise<IGitSourceSettings> {
'TRUE' 'TRUE'
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`) core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`)
// The default self-checkout (this repository with no explicit ref) always
// resolves to the trusted ref/commit GitHub set for the triggering event, so
// the fork-checkout guard only needs to run when the caller customized the
// repository or ref.
const isDefaultCheckout = isWorkflowRepository && !core.getInput('ref')
if (!isDefaultCheckout) {
unsafePrCheckoutHelper.assertSafePrCheckout({ unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository, qualifiedRepository,
ref: result.ref, ref: result.ref,
commit: result.commit, commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout allowUnsafePrCheckout: result.allowUnsafePrCheckout
}) })
}
return result return result
} }